QML vs Mapbox GL JSON for Style Portability

QML is QGIS-native XML with rich, deeply layered symbology driven by QGIS expressions — it offers excellent desktop fidelity but poor portability to web renderers; Mapbox GL JSON is a web and vector-tile format built around a functional expression array and zoom stops that is portable to browser renderers but constrained to a flatter, single-symbol model. Choosing between them, or converting one to the other, comes down to three axes: how each encodes expressions, how much of the symbol model survives the trip, and how data-driven styling is evaluated. This reference maps those axes and gives a Python conversion sketch that flags exactly what is lost.

Core Model and Workflow

The two formats solve different problems. QML serialises the full QGIS symbology tree — renderers, stacked symbol layers, draw effects, and per-property data-defined overrides — as XML that only QGIS (and libraries wrapping its API) can fully interpret. Mapbox GL JSON describes a render-ready style for a GPU vector-tile pipeline: a flat layers array where each entry binds one source layer to one paint type, with all conditional logic expressed as nested arrays. Understanding portability means understanding where the QML tree collapses when projected onto the GL model.

QML symbology tree mapped onto the Mapbox GL JSON flat layer model Left column shows a QML renderer containing a symbol with two stacked symbol layers and a data-defined override. Arrows map the renderer to a source-layer filter, each symbol layer to its own Mapbox GL layer, and the data-defined override to a GL expression array. A dashed box marks constructs with no GL equivalent. QML (QGIS XML tree) categorizedSymbol renderer attr = "highway" symbol: line (2 stacked layers) SimpleLine casing (width 6) SimpleLine fill (width 3) data-defined dash + draw effect (no GL equivalent) Mapbox GL JSON (flat) filter: ["match", ["get","highway"] ...] source-layer binding layer #1 — line casing (below) layer #2 — line fill (above) line-dasharray: constant only effect dropped, warning logged

Read the diagram left to right. The QML renderer’s classification attribute becomes a GL filter plus a data-driven paint expression. Each stacked QML symbol layer becomes its own GL layer, ordered explicitly in the layers array, because GL has no notion of sub-symbols inside a single entry. Constructs in the dashed box — data-defined dash arrays, draw effects — have no target and must be flagged rather than silently dropped. This same rule-to-layer flattening underpins any portable engine, which is why it is worth building the conversion as an explicit, inspectable step rather than trusting a black-box exporter; the design tradeoffs are explored further in Rule-Based Styling Engines.

Expression models compared

QGIS expressions are an infix, SQL-like language evaluated per feature on the CPU: CASE WHEN "population" > 100000 THEN 'red' ELSE 'grey' END, with field references as bare quoted names and access to variables like @map_scale. Mapbox GL expressions are prefix JSON arrays evaluated in the render pipeline: the same logic becomes ["case", [">", ["get", "population"], 100000], "red", "grey"]. Field access is ["get", "population"], categorical branching is ["match", ...], and — critically — scale logic is not an expression variable but a zoom function expressed with ["step", ["zoom"], ...] or ["interpolate", ["linear"], ["zoom"], ...]. The mental shift is that QGIS asks “what does this feature evaluate to right now” while GL bakes a function of zoom and feature attributes that the GPU samples continuously.

Symbol coverage compared

QML’s symbol model is the richer of the two. A single QGIS symbol stacks arbitrarily many symbol layers (fills, outlines, marker lines, centroid fills, geometry generators), each with its own data-defined overrides and optional draw-effect stack. Mapbox GL exposes a fixed palette of paint types — fill, line, symbol, circle, fill-extrusion, heatmap, raster, hillshade — and one paint object per layer. Anything that QGIS renders by compositing multiple symbol layers must be decomposed into multiple GL layers with a hand-managed draw order. Marker lines placed on vertices, hatched fills, and geometry generators have no direct GL counterpart at all.

Production-Ready Python Conversion Sketch

The converter below parses a QML file with xml.etree.ElementTree (Python 3.11 standard library), detects the renderer type, and emits a Mapbox GL Style Spec fragment. It deliberately returns a warnings list so lossy conversions are visible in CI rather than discovered visually. It targets categorized and graduated renderers — the two most common portable cases — and flags the rest.

"""qml_to_gl.py — sketch converter, QGIS 3.34 QML -> Mapbox GL Style Spec.

Standard library only (xml.etree.ElementTree). Emits a GL 'layers' fragment
plus a warnings list enumerating every construct with no Style Spec target.
"""
from __future__ import annotations

import xml.etree.ElementTree as ET
from dataclasses import dataclass, field


# Constructs QGIS can render but the Mapbox GL Style Spec cannot represent.
LOSSY_SYMBOL_LAYERS = {
    "GeometryGenerator", "MarkerLine", "PointPattern",
    "LinePatternFill", "RasterFill", "SVGFill",
}


@dataclass
class ConversionResult:
    layers: list[dict] = field(default_factory=list)
    warnings: list[str] = field(default_factory=list)


def _prop(symbol_layer: ET.Element, key: str, default: str = "") -> str:
    """Read a QML <Option> or legacy <prop> value by key."""
    opt = symbol_layer.find(f".//Option[@name='{key}']")
    if opt is not None and "value" in opt.attrib:
        return opt.attrib["value"]
    legacy = symbol_layer.find(f".//prop[@k='{key}']")
    return legacy.attrib["v"] if legacy is not None else default


def _qgis_color_to_hex(qgis_rgba: str) -> str:
    """Convert a QGIS '229,182,54,255' color string to '#e5b636'."""
    parts = [int(c) for c in qgis_rgba.split(",")[:3]] or [0, 0, 0]
    return "#{:02x}{:02x}{:02x}".format(*parts)


def _paint_type(geom_type: str) -> str:
    """Map a QGIS geometry class to a GL paint layer type."""
    return {"line": "line", "fill": "fill", "marker": "circle"}.get(geom_type, "fill")


def convert_categorized(root: ET.Element, source_layer: str) -> ConversionResult:
    """Translate a categorizedSymbol renderer into GL layers + a match expression."""
    result = ConversionResult()
    renderer = root.find(".//renderer-v2")
    if renderer is None or renderer.attrib.get("type") != "categorizedSymbol":
        result.warnings.append("renderer is not categorizedSymbol; skipped")
        return result

    attr = renderer.attrib.get("attr", "")
    # Build a GL 'match' mapping each category value to its symbol's primary color.
    match_expr: list = ["match", ["get", attr]]
    symbols = {s.attrib["name"]: s for s in renderer.findall(".//symbols/symbol")}

    for cat in renderer.findall(".//category"):
        value = cat.attrib["value"]
        symbol = symbols.get(cat.attrib["symbol"])
        if symbol is None:
            continue
        layers = symbol.findall("layer")
        # GL cannot stack sub-symbols: warn when a QML symbol has more than one.
        if len(layers) > 1:
            result.warnings.append(
                f"category '{value}': {len(layers)} stacked symbol layers "
                f"flattened to draw-ordered GL layers"
            )
        for sl in layers:
            cls = sl.attrib.get("class", "")
            if cls in LOSSY_SYMBOL_LAYERS:
                result.warnings.append(
                    f"category '{value}': symbol layer '{cls}' has no GL "
                    f"equivalent and was dropped"
                )
        primary = layers[0] if layers else None
        color = _qgis_color_to_hex(_prop(primary, "color", "0,0,0,255")) if primary else "#000000"
        match_expr += [value, color]

    match_expr.append("#cccccc")  # GL 'match' requires a fallback default.

    geom_type = symbols and next(iter(symbols.values())).attrib.get("type", "fill") or "fill"
    result.layers.append({
        "id": f"{source_layer}-{attr}",
        "type": _paint_type(geom_type),
        "source-layer": source_layer,
        "paint": {f"{_paint_type(geom_type)}-color": match_expr},
    })
    return result


def convert_qml(path: str, source_layer: str) -> ConversionResult:
    """Entry point: parse a .qml file and dispatch on renderer type."""
    root = ET.parse(path).getroot()
    renderer = root.find(".//renderer-v2")
    rtype = renderer.attrib.get("type") if renderer is not None else None

    if rtype == "categorizedSymbol":
        return convert_categorized(root, source_layer)

    result = ConversionResult()
    result.warnings.append(
        f"renderer type '{rtype}' not yet supported "
        f"(graduatedSymbol -> ['step', ...]; RuleRenderer -> per-rule filters)"
    )
    return result


if __name__ == "__main__":
    out = convert_qml("roads.qml", source_layer="transportation")
    import json
    print(json.dumps({"layers": out.layers}, indent=2))
    for w in out.warnings:
        print("WARN:", w)

The emitted GL fragment is a plain data structure you can merge into a full style document. A graduated renderer follows the same shape but produces a ["step", ["get", attr], ...] expression keyed on class breaks, and a rule-based QML renderer maps each rule’s filter expression to a separate GL layer with its own filter array. For the reverse direction and a schema-validated round trip, the JSON-based rule styling engine for QGIS shows how to keep a neutral intermediate representation that both formats serialise from.

Portability Tradeoffs and Best Practices

  • Treat conversion as lossy by contract. Always surface a warnings list and fail CI when the loss set exceeds a threshold, rather than shipping a style that renders differently in the browser than on the desktop. Geometry generators, marker lines, and draw effects are the usual casualties.
  • Flatten stacked symbols to explicit draw order. A QML road casing (wide dark line under a narrow light line) must emit two GL layers, casing first. Get the order wrong and the casing paints over the fill. Keep the ordering deterministic by suffixing layer ids with a numeric rank.
  • Move scale logic into zoom stops, not filters. QGIS @map_scale comparisons belong in GL ["interpolate", ["linear"], ["zoom"], ...] or ["step", ["zoom"], ...], not in the filter. Encoding width or color as a zoom function is what makes the GL style smooth across zoom levels instead of popping at breakpoints — the scale-denominator mapping is covered under scale mapping for web and print.
  • Pre-compute attributes GL cannot derive. Any QGIS expression using aggregate, geometry_generator, or relations must be materialised into a tile attribute before styling, because GL expressions cannot query other features or generate geometry. Bake these during vector tile generation.
  • Validate against the Style Spec, not just JSON. A syntactically valid JSON document can still be an invalid GL style (unknown paint property, malformed expression). Run the output through a Style Spec validator so a wrong match arity or a missing fallback is caught before it reaches a renderer.

Integration and Next Steps

The converter output is designed to slot into a larger automation pipeline rather than stand alone. Two integration paths dominate:

  • Web delivery. Merge the GL layers fragment into a base style document alongside sources, glyphs, and sprite URLs, then serve it to MapLibre GL JS or a native GL renderer. Because the paint expressions are already data-driven, the same style scales across zoom levels without per-scale duplication. Shared design tokens (palette, dark and light variants) are best factored out with the patterns in theme inheritance systems.
  • Server-side raster. When the target is a rendered PNG rather than an interactive map, the same GL JSON feeds a headless renderer. Keeping the style in GL JSON means one style artefact drives both the interactive and the batch-raster outputs — see headless rendering engines for the server topology.

For teams standardising on QGIS for authoring but shipping to the web, the durable pattern is a neutral intermediate style model with QML and GL JSON as two serialisers, each with an explicit capability manifest. That keeps the lossy edges visible and versioned instead of rediscovered every time a cartographer adds a geometry generator that never survives the export.


Frequently Asked Questions

Why does a single QML symbol become multiple Mapbox GL layers?

A QML symbol can stack several symbol layers — for example a wide casing SimpleLine beneath a narrow SimpleLine — rendered together as one visual symbol. The Mapbox GL Style Spec has no sub-layer concept: each paint stack must be its own entry in the layers array with its own paint object and an explicit z-order. A two-layer road casing therefore expands to two GL line layers, casing first and fill second, and getting that order wrong repaints the casing over the fill.

Do QGIS expressions translate directly to Mapbox GL expressions?

No. QGIS uses an infix, SQL-like expression language evaluated per feature on the CPU, while Mapbox GL uses a prefix JSON-array language evaluated in the render pipeline per zoom and per feature. CASE WHEN maps to ["match", ...] or ["case", ...], field names map to ["get", "name"], and @map_scale-based logic maps to zoom stops via ["step", ...] or ["interpolate", ...]. Functions with no GL counterpart — geometry_generator, aggregate, relation lookups — cannot be expressed and must be pre-computed into tile attributes.

What cartographic detail is lost converting QML to Mapbox GL JSON?

Geometry generators, marker-line placement along vertices, data-defined dash arrays, per-symbol blend modes, and QGIS draw-effect stacks (drop shadows, outer glow) have no Style Spec equivalent. Label placement models also differ: QGIS PAL uses a candidate-cost solver while GL uses a greedy on-GPU collision index, so exact label positions will not match even when the text-field expression is identical. A good converter logs each of these rather than dropping them silently.


Back to Rule-Based Styling Engines