Style Validation Workflows for Vector Tile Pipelines

Style validation is the automated gate that prevents broken, semantically invalid, or performance-degrading MapLibre GL style documents from reaching production tile servers. Without a validation pipeline, a single malformed expression or orphaned source-layer reference can silently break rendering across every client that pulls from your CDN-hosted style endpoint. This page covers the complete validation chain — from JSON schema enforcement through CI/CD gatekeeping — as a concrete implementation guide for teams running automated vector tile generation and map styling pipelines.

Prerequisites

Before building out the validation pipeline, the following must be in place:

Requirement Detail
Python 3.10+ jsonschema>=4.21, requests>=2.31, aiohttp>=3.9
Node.js 18+ (optional) @maplibre/maplibre-gl-style-spec for JS-side expression linting
Style spec schema Local copy of the MapLibre GL style spec JSON Schema (tag-pinned version)
Tile server with TileJSON Staging endpoint that serves /<source>/tilejson.json; needed for layer-dependency checks
CI runner GitHub Actions, GitLab CI, or Jenkins; must support network egress from the integration stage
Test fixture set A representative bundle of style JSON files covering valid styles, known-bad expressions, and edge-case geometries

Familiarity with the MapLibre GL JSON structure — especially how sources, layers, paint, and layout properties compose — is essential before writing custom validators. The schema version declared in your style’s version field (always 8 for MapLibre) determines which spec rules apply.

Validation Pipeline Architecture

The following diagram shows the four sequential gates that every style document traverses before it is eligible for deployment. Each gate isolates a distinct failure domain so errors surface early and cheaply.

Four phases, ordered so the cheapest failure happens firstSchema compliance, dependency resolution, expression linting and a rendered smoke test, each blocking the next.VALIDATION PHASESSchemagl-style-specmillisecondsDependenciessources, sprite, glyphsnetworkExpressionsattribute referencesthe check that catchesrenamesRenderheadless smoke testseconds
Ordering is the design. A style that fails the schema should never reach the phase that spins up a browser.

Core Concept: Validation Phases and Their Parameters

Which phase catches which class of breakageFive failure classes checked against the four validation phases, showing that schema validation alone catches only one of them.WHAT CATCHES WHATschemadependenciesexpressionsrenderMisspelled paint propertyCaught--CaughtRenamed source-layerMissed-CaughtCaught404 on sprite or glyphsMissedCaught-CaughtAttribute dropped fromthe buildMissedMissedCaughtMissedLayers in the wrong orderMissedMissedMissedCaught
The point of the table is the top-right corner: a style can be perfectly valid and render nothing. Only the last two phases see that.

Each validation phase targets a distinct layer of style correctness. The table below maps each phase to the specific checks it performs and the tooling involved.

Phase What is checked Tooling Blocks pipeline?
Schema compliance Required top-level keys (version, sources, layers), property types, enum values, deprecated fields jsonschema (Python) or ajv (Node) against spec v8 schema Yes — hard fail
Source & layer dependencies sources URL reachability, source-layer names vs. TileJSON vector_layers, minzoom/maxzoom bounds requests + TileJSON diff Yes — hard fail
Expression linting Nested case/match depth, missing fallback values in ["get", ...], type coercion mismatches, null propagation paths maplibre-gl-style-spec expression validator or custom AST walker Yes — hard fail
CI/CD gate All of the above, plus headless render smoke test GitHub Actions / GitLab CI matrix Yes — blocks deploy

Step-by-Step Implementation

Step 1: Install and pin dependencies

Pin exact versions to keep CI deterministic across runners and developer machines:

bash
pip install "jsonschema==4.21.1" "requests==2.31.0" "aiohttp==3.9.3" "pytest==8.1.1"

Fetch a tag-pinned copy of the MapLibre style spec JSON Schema and commit it to your repository:

bash
curl -fsSL \
  https://raw.githubusercontent.com/maplibre/maplibre-style-spec/v20.3.1/src/reference/v8.json \
  -o style-schemas/maplibre-v8.json

Verify: jsonschema --instance your-style.json style-schemas/maplibre-v8.json should exit 0.

Step 2: Schema compliance validator

python
import json
import sys
from pathlib import Path
from jsonschema import validate, ValidationError, SchemaError
from jsonschema.validators import Draft7Validator

def validate_style_schema(style_path: str, schema_path: str) -> list[str]:
    """Return a list of human-readable violation strings (empty = pass)."""
    style = json.loads(Path(style_path).read_text())
    schema = json.loads(Path(schema_path).read_text())

    # Confirm version before running spec rules
    if style.get("version") != 8:
        return [f"version must be 8, got {style.get('version')!r}"]

    validator = Draft7Validator(schema)
    errors = sorted(validator.iter_errors(style), key=lambda e: list(e.absolute_path))
    return [
        f"{list(e.absolute_path)}: {e.message}"
        for e in errors
    ]

if __name__ == "__main__":
    violations = validate_style_schema(sys.argv[1], "style-schemas/maplibre-v8.json")
    for v in violations:
        print(f"SCHEMA: {v}")
    sys.exit(1 if violations else 0)

What to verify after this step: zero schema violations reported; version is 8; no deprecated ref properties flagged.

Step 3: Source and layer dependency resolution

Every layer object’s source-layer must match a name field in the TileJSON vector_layers array returned by the source’s tile server. Orphaned references fail silently in the renderer.

python
import requests
from typing import Optional

def fetch_tilejson(source_url: str, timeout: int = 8) -> Optional[dict]:
    """Fetch TileJSON from a tile server source URL; return None on failure."""
    try:
        resp = requests.get(source_url, timeout=timeout)
        resp.raise_for_status()
        return resp.json()
    except requests.RequestException as exc:
        print(f"TileJSON fetch failed for {source_url}: {exc}")
        return None

def check_layer_dependencies(style: dict) -> list[str]:
    errors: list[str] = []
    sources = style.get("sources", {})

    # Build a map of source_id -> set of available source-layer names
    available: dict[str, set[str]] = {}
    for src_id, src_def in sources.items():
        if src_def.get("type") != "vector":
            continue
        url = src_def.get("url") or (src_def.get("tiles") or [""])[0]
        # Normalise martin-style URLs: strip /{z}/{x}/{y}.pbf suffix
        tilejson_url = url.split("/{z}")[0] + "/tilejson.json"
        tj = fetch_tilejson(tilejson_url)
        if tj is None:
            errors.append(f"source '{src_id}': could not fetch TileJSON from {tilejson_url}")
            available[src_id] = set()
        else:
            available[src_id] = {
                layer["id"] for layer in tj.get("vector_layers", [])
            }

    for layer in style.get("layers", []):
        src = layer.get("source")
        sl = layer.get("source-layer")
        if src and sl:
            known = available.get(src)
            if known is not None and sl not in known:
                errors.append(
                    f"layer '{layer['id']}': source-layer '{sl}' not found in source '{src}' "
                    f"(available: {sorted(known)})"
                )
    return errors

What to verify: no orphaned source-layer names; minzoom/maxzoom within the tile server’s advertised range (also available in the TileJSON response).

When teams implement dynamic attribute mapping with runtime data joins, this dependency check becomes even more critical — a missing source-layer breaks the attribute binding before any expression runs.

Step 4: Expression linting

Data-driven expressions like ["interpolate", ["linear"], ["get", "population"], 0, "#eee", 1e6, "#111"] fail silently when population is absent on a feature. The linter below walks the AST and flags missing fallbacks and excessive nesting.

python
import json

MAX_EXPRESSION_DEPTH = 8  # renderer-specific; reduce if targeting low-end mobile

def walk_expression(expr, depth=0) -> list[str]:
    """Recursively walk a style expression AST and return issues."""
    issues: list[str] = []
    if not isinstance(expr, list) or not expr:
        return issues

    op = expr[0]

    if depth > MAX_EXPRESSION_DEPTH:
        issues.append(f"expression depth {depth} exceeds limit ({MAX_EXPRESSION_DEPTH}): {json.dumps(expr)[:80]}")

    # ["get", "field"] with no fallback risks null propagation
    if op == "get" and len(expr) == 2:
        issues.append(
            f'["get", "{expr[1]}"] has no fallback — wrap with ["coalesce", ["get", "{expr[1]}"], <default>]'
        )

    # ["match", input, ...pairs, fallback] — fallback is required (odd total length = missing)
    if op == "match" and len(expr) % 2 == 0:
        issues.append(f'"match" expression missing fallback value: {json.dumps(expr)[:80]}')

    for child in expr[1:]:
        if isinstance(child, list):
            issues.extend(walk_expression(child, depth + 1))

    return issues

def lint_expressions(style: dict) -> list[str]:
    all_issues: list[str] = []
    for layer in style.get("layers", []):
        for prop_group in ("paint", "layout", "filter"):
            for key, value in (layer.get(prop_group) or {}).items():
                if isinstance(value, list):
                    for issue in walk_expression(value):
                        all_issues.append(f"layer '{layer['id']}' {prop_group}.{key}: {issue}")
    return all_issues

What to verify: zero ["get"] without coalesce fallbacks; no match expressions lacking a default; expression depth within limits.

Step 5: CI/CD integration

What each validation phase costs in CIFour phases timed in a CI job: schema validation, dependency probes, expression linting and a headless render.CI BUDGET41 s for the full suite2341Schema — 1 s2Dependencies — 6 s3Expressions — 3 s4Headless render — 31 s
Three quarters of the budget is the render phase. Running the first three on every commit and the render on merge is the usual compromise.

Run all three validators in parallel across every pull request targeting main. The example below uses GitHub Actions; the same logic adapts to GitLab CI by replacing jobs syntax.

yaml
# .github/workflows/style-validate.yml
name: Style validation

on:
  pull_request:
    paths:
      - "styles/**/*.json"

jobs:
  schema:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install "jsonschema==4.21.1"
      - run: python scripts/validate_schema.py styles/production.json

  dependencies:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: pip install "requests==2.31.0"
      - run: python scripts/validate_deps.py styles/production.json
        env:
          TILE_SERVER: $

  expressions:
    runs-on: ubuntu-22.04
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with: { python-version: "3.12" }
      - run: python scripts/validate_expressions.py styles/production.json

  deploy:
    needs: [schema, dependencies, expressions]
    runs-on: ubuntu-22.04
    if: github.event_name == 'push' && github.ref == 'refs/heads/main'
    steps:
      - run: echo "All validation gates passed — deploy proceeds"

The deploy job’s needs array enforces that all three validation jobs must pass before any deployment step runs.

What to Do With a Failing Check

A validation suite is only useful if a failure has an obvious next action, and the four phases differ sharply in what a failure means.

A schema failure is always a code fix. The style is malformed, the message names the property and the path, and the fix is local. These should never reach a review — they belong in an editor integration or a pre-commit hook, where the loop is seconds rather than minutes.

A dependency failure is usually environmental. A 404 on a sprite or a glyph range more often means the asset was not deployed than that the style is wrong. The useful discipline is to report the URL that failed rather than the style key that referenced it, because the person reading the failure needs to go and look at a bucket, not at JSON.

An expression failure is a contract failure, and it is the interesting one. It means the style and the tileset have diverged, and the correct response depends on which side moved. If the tile build dropped an attribute deliberately, the style must stop reading it. If it dropped one accidentally, the build is the bug. The check cannot tell these apart, so the failure message should print both sides — what the style asked for and what the tileset publishes — and let a human decide in ten seconds rather than ten minutes.

A render failure is the hardest to act on and the most valuable, because it is the only phase that sees the map as a reader does. Its output should be an image, not a log line. A diff against a stored reference render turns “something looks wrong” into a specific region of a specific tile, and without that a render check tends to be disabled after its second false alarm.

Keeping the Suite Fast Enough to Stay On

The most common end state for a validation suite is not failure; it is being skipped. Every check added to a merge path is a tax on the team’s iteration speed, and a suite that takes several minutes gets a --no-verify habit built around it.

Two structural choices keep the cost down. Run the three static phases on every commit and the render phase only on merge to the main branch, which puts the expensive check where its latency does not block iteration. And validate against a schema fixture rather than a live tileset, so that no phase before the render needs the network — a suite that fails when a staging bucket is briefly unreachable teaches people to ignore its results.

Optimization Knobs

Parameter Lower value Higher value Recommendation
MAX_EXPRESSION_DEPTH Catches deeply nested expressions earlier; may flag legitimate complex styles Allows more nesting; misses performance traps Start at 8; tune down if targeting WebGL 1 mobile renderers
TileJSON cache TTL Always fresh; slower CI Stale risk on source changes; faster CI Cache for 5 minutes in local dev; disable cache on PRs
Schema spec version Pinned older version; misses new spec fields Pinned to HEAD; may break on unstable spec changes Pin to a release tag (e.g., v20.3.1); upgrade deliberately

Integration with Adjacent Pipeline Stages

Style validation sits between tile generation and CDN delivery in the broader pipeline:

  1. Tile generation (Tippecanoe or martin) outputs .mbtiles or .pmtiles files and registers them with the tile server. The TileJSON endpoint becomes available at this point — validation can only run against a live or staging tile server after this step completes.
  2. Style validation (this page) consumes the tile server’s TileJSON and validates style JSON against the available vector layers.
  3. CDN delivery begins only after validation passes. The MapLibre GL JSON structure conventions governing sources URLs and versioned style endpoints must align with the CDN’s cache-control headers configured at this stage.
  4. Theme inheritance builds on validated base styles — child themes should re-run the schema and expression linting stages after merge, since overrides can introduce new violations.

The style’s sources block must declare tile URLs that resolve correctly on CDN (e.g., https://tiles.example.com/admin/{z}/{x}/{y}.pbf). If attribute filtering rules in Tippecanoe dropped a field that a style expression references, the expression linter will catch the null-propagation risk before it reaches the browser.

Troubleshooting

1. Schema validator rejects a valid style property

Symptom: ValidationError: Additional properties are not allowed ('transition' was unexpected) on a paint block that is visually valid.

Diagnosis: The pinned schema version predates the transition property. Run:

bash
python -c "import jsonschema; print(jsonschema.__version__)"

Fix: Upgrade the pinned spec file to a tag that includes transition support, then re-pin the hash in requirements.txt.

2. TileJSON probe returns 404

Symptom: TileJSON fetch failed for https://staging.tiles.example.com/.../tilejson.json: 404

Diagnosis: The tile server route pattern differs from the assumed /<source>/tilejson.json format. Inspect the server’s API:

bash
curl -s https://staging.tiles.example.com/ | python -m json.tool | grep tilejson

Fix: Update the URL construction logic in fetch_tilejson() to match the actual route; commit the corrected pattern to the shared validation config.

3. Expression linter reports false positives on legitimate ["get"] usage

Symptom: ["get", "id"] inside an ["id"]-equivalent context triggers a fallback warning.

Diagnosis: The linter does not yet differentiate context. Confirm the false positive:

python
# Temporarily allow-list specific fields
COALESCE_EXEMPT = {"id", "class"}
if op == "get" and expr[1] in COALESCE_EXEMPT:
    return issues  # skip fallback check

Fix: Extend COALESCE_EXEMPT for well-known non-nullable fields; document the exemptions with a comment so they are reviewed during spec upgrades.

4. CI dependency job fails because staging tile server is unreachable

Symptom: requests.exceptions.ConnectionError: Failed to establish a new connection

Diagnosis: The CI runner has no egress to the staging tile server (firewall rule or missing VPN step). Fix: Either allow-list the runner’s IP range in the firewall, or mock TileJSON responses in CI using a fixture file:

python
# In pytest fixtures
import pytest
@pytest.fixture
def mock_tilejson(monkeypatch):
    def _fetch(url, timeout=8):
        return {"vector_layers": [{"id": "roads"}, {"id": "admin"}]}
    monkeypatch.setattr("validate_deps.fetch_tilejson", _fetch)

5. Merged style passes validation but renders blank on mobile

Symptom: Desktop Chrome renders correctly; iOS Safari shows a blank tile area.

Diagnosis: An expression uses ["feature-state", ...] which requires WebGL state updates — not supported in all renderer versions. Check the style’s renderer target:

bash
grep -r "feature-state" styles/ | wc -l

Fix: Replace feature-state expressions with ["get", "property"] for static attributes, or constrain the feature-state usage to layers that only render on WebGL 2 capable clients.

FAQ

Why is schema validation not enough?

Because it cannot see the tiles. A style can be perfectly valid and reference a layer that does not exist, an attribute the build dropped, or a sprite URL that 404s.

What should run on every commit?

The three static phases — schema, dependencies and expressions. The rendered smoke test belongs on merge, where its thirty seconds do not tax iteration.

What does an expression failure mean?

That the style and the tileset have diverged. Which side is wrong depends on whether the build dropped the attribute deliberately, so the message should print both sides and let a human decide.

How do I keep the suite from being skipped?

Keep it fast and keep it offline. A suite that takes minutes, or that fails when a staging bucket is briefly unreachable, teaches people to ignore its results.

Child Pages


Parent: Map Styling & Layer Synchronization

Next reading Validating Styles in CI with the GL Style Spec