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.

Style validation pipeline stages A horizontal flow diagram showing four sequential validation stages connected by arrows: 1 Schema Compliance, 2 Source and Layer Dependencies, 3 Expression Linting, 4 CI/CD Gate. A style JSON file feeds in from the left; a green deploy arrow exits to the right. Style JSON 1 Schema Compliance jsonschema draft-07 2 Source & Layer Deps TileJSON probe 3 Expression Linting AST static analysis 4 CI/CD Gate Parallel stages Deploy block merge block merge block merge

Core Concept: Validation Phases and Their Parameters

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

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.

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.

Child Pages


Parent: Map Styling & Layer Synchronization

Next reading Validating Styles in CI with the GL Style Spec