Data-Driven Styling with MapLibre Expressions from Python

Part of the Symbology & Data-Driven Styling guide.

Operative rule: generate the expression from the same frozen ramp object Python already owns — never hand-write a step array in a style file, because a hand-written one is a second source of truth that no test will ever compare against the first.

How Expressions Change the Build

In a Folium pipeline the classification runs at build time and the result is baked into the exported HTML. In a MapLibre pipeline the classification travels as data inside the style document and runs on the GPU, once per feature per frame. That difference has a practical consequence worth designing around: changing a threshold no longer requires regenerating tiles, purging a CDN or waiting for a rebuild. It requires editing a JSON file.

The trade is that the attribute must survive into the tiles. An expression can only read properties that the tiling step retained, so the field list in your ramp and the retained-attribute list handed to the tile generator have to come from the same place — the coupling described in generating vector tiles from PostGIS with Tippecanoe.

Where the classification runs, and what a change costs In the baked pipeline Python classifies at build time, the colour is written into the artifact, and changing a threshold means a full rebuild and cache purge. In the expression pipeline Python emits the ramp into the style document, the GPU classifies per frame, and changing a threshold means editing the style and reloading. Two places the same arithmetic can happen baked — Python classifies at build time colours written into the artifact · renderer just paints what it was given cost of a threshold change: rebuild → republish → purge → wait expression — GPU classifies per frame ramp travels in the style document · tiles carry geometry and attributes only cost of a threshold change: edit the style → reload · no rebuild, no purge

Production-Ready Implementation

from __future__ import annotations

import json
from dataclasses import dataclass


@dataclass(frozen=True)
class Ramp:
    field: str
    breaks: list[float]      # k+1 edges, ascending
    colors: list[str]        # k colours
    nodata_color: str = "#E6E6E6"


def step_color_expression(ramp: Ramp) -> list:
    """["step", ["to-number", ["get", field], -1], nodata, b0, c0, b1, c1, …]

    The -1 fallback makes a missing or non-numeric property land below the
    first break, where it picks up the explicit no-data colour.
    """
    expr: list = [
        "step",
        ["to-number", ["get", ramp.field], -1],
        ramp.nodata_color,          # output below the first stop
    ]
    for edge, color in zip(ramp.breaks[:-1], ramp.colors):
        expr.append(edge)
        expr.append(color)
    return expr


def interpolate_radius_expression(
    field: str, max_value: float, min_px: float = 2.0, max_px: float = 24.0
) -> list:
    """Area-proportional radius, expressed with a square-root interpolation."""
    stops: list = []
    for fraction in (0.0, 0.25, 0.5, 0.75, 1.0):
        value = max_value * fraction
        radius = min_px + (max_px - min_px) * (fraction ** 0.5)
        stops.extend([value, round(radius, 2)])
    return ["interpolate", ["linear"], ["to-number", ["get", field], 0], *stops]


def zoom_opacity_expression(low: float = 0.85, high: float = 0.45) -> list:
    """Fade fills as the reader zooms in so the basemap shows through."""
    return ["interpolate", ["linear"], ["zoom"], 6, low, 14, high]


RAMP = Ramp(
    field="incidents_per_1k",
    breaks=[0.0, 2.0, 5.0, 10.0, 20.0, 50.0],
    colors=["#F1EEF6", "#BDC9E1", "#74A9CF", "#2B8CBE", "#045A8D"],
)

layer = {
    "id": "regions-fill",
    "type": "fill",
    "source": "regions",
    "source-layer": "regions",
    "paint": {
        "fill-color": step_color_expression(RAMP),
        "fill-opacity": zoom_opacity_expression(),
        "fill-outline-color": "#FFFFFF",
    },
}

print(json.dumps(layer, indent=2))

The to-number wrapper with an explicit fallback is what makes this robust in an automated pipeline. Tile generators are inconsistent about numeric types — a column that is an integer in PostGIS can arrive as a string in a tile depending on the encoder — and MapLibre’s comparisons are strict. Coercing in the expression means a type change upstream degrades to the no-data colour instead of silently painting the entire layer one shade.

Expression Types Worth Knowing

Four expression forms and what each is for step takes a number and produces discrete classes, suiting choropleths. interpolate takes a number and blends between stops, suiting sizes, opacity and zoom-dependent styling. match takes a value and compares it against literals, suiting categorical styling by status or type. case evaluates boolean conditions in order, suiting rules that combine several properties. Four forms cover essentially all dashboard styling ["step", …] number → discrete classes choropleth fills, banded categories — the reader is meant to see boundaries ["interpolate", …] number → smooth blend radius, opacity, line width, and anything keyed on zoom ["match", …] value → literal comparison status, operator, land use — always give it a default output ["case", …] boolean rules in order — combining several properties, or feature-state highlighting

How an Expression Is Evaluated

Reading a step expression in the order the renderer does makes its behaviour obvious, including the two edge cases that cause most confusion: what happens below the first stop, and what happens to a value that equals a stop exactly.

How a step expression resolves a value The input is coerced to a number. If it is below the first stop the fallback output is used. Otherwise the renderer selects the output attached to the highest stop that is less than or equal to the value, so a value exactly on a boundary belongs to the class above it. Reading ["step", input, fallback, 0, c0, 2, c1, 5, c2, …] 1 · coerce the input — to-number with a fallback, never a bare get 2 · below the first stop → the fallback output (put no-data here) 3 · otherwise → the output of the highest stop ≤ the value a value exactly on a boundary belongs to the class ABOVE it — label your legend the same way

Keeping the Style Document Generated

Once expressions carry the symbology, the style document becomes a build artifact rather than a hand-maintained file, and it should be treated like one. Generate it from the same Python module that owns the ramp, write it to a versioned path alongside the tiles, and let the page reference that path.

Doing so buys three things. The style can be diffed between releases, so a change in symbology shows up in review as a readable change rather than as an opaque binary difference. It can be validated in the build, catching an invalid expression before deployment rather than as a blank map. And it can be rolled back independently of the data, which is exactly what you want when a threshold change turns out to have been wrong: the tiles are fine, only the interpretation was.

The one discipline this requires is that nothing edits the deployed style by hand. A quick production fix applied directly to the style file is invisible to the generator, and the next rebuild silently reverts it — a failure mode that is both confusing and slow to diagnose, because the map was demonstrably correct an hour ago. Treat the style exactly as you treat the tiles: generated, versioned, immutable, and replaced only by another build.

A generated style also makes the dashboard’s symbology auditable by people who do not read Python. The style document is JSON, the expressions in it read almost like a table of thresholds, and a colleague reviewing a proposed change can see exactly which numbers moved without opening the pipeline at all.

Verification Steps

  • Serialise the layer to JSON and validate it against a style linter before deploying — an invalid expression fails the whole style, not just one layer.
  • Load the map and set the property of one feature to a string; confirm it renders as no-data rather than changing the whole layer.
  • Change one break value in the style, reload, and confirm the map updates with no rebuild.
  • Confirm the legend was regenerated from the same ramp object in the same run — a style edit that skips the legend is the failure this pattern is meant to prevent.
  • Profile a frame at full zoom-out: expressions evaluate per feature per frame, and an over-complicated case chain is measurable.

Common Errors & Fixes

Every feature renders in the fallback colour

The property name in ["get", …] does not match the attribute in the tile. Tile generators often lower-case or rename fields; inspect a tile’s actual property names rather than trusting the source schema.

The map is correct at one zoom and wrong at another

A zoom-dependent expression is nested inside a data-dependent one in the wrong order. Keep ["zoom"] as the outermost input of any interpolation — MapLibre requires it and silently misbehaves in some nesting arrangements.

Colours shift after enabling a hover highlight

The highlight is implemented with case over feature state and returns a different base colour than the step expression. Compose them: evaluate the step expression as the default branch of the case so highlighting only overrides, never replaces.

The style file has grown unreadable

Generate it. A hand-maintained style document with three data-driven layers is already past the point where a Python generator pays for itself, and it is the only way to keep the ramp and the legend in step.