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.
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:
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:
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
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.
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.
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.
# .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:
- Tile generation (Tippecanoe or martin) outputs
.mbtilesor.pmtilesfiles 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. - Style validation (this page) consumes the tile server’s TileJSON and validates style JSON against the available vector layers.
- CDN delivery begins only after validation passes. The MapLibre GL JSON structure conventions governing
sourcesURLs and versioned style endpoints must align with the CDN’s cache-control headers configured at this stage. - 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:
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:
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:
# 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:
# 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:
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
- Binding Data-Driven Properties to Vector Layers — implements the expression patterns this page validates, covering
interpolate,match, andcaseoperators with per-zoom attribute binding. - Validating Styles in CI with the GL Style Spec — running
@maplibre/maplibre-gl-style-specvalidate()in CI, then cross-checking everysource-layerand["get", …]key against the actual tile schema.
Parent: Map Styling & Layer Synchronization
Related
- MapLibre GL JSON Structure — the specification reference for the top-level keys and layer properties that the schema validator checks.
- Dynamic Attribute Mapping — explains how runtime data joins depend on the
source-layernames that dependency resolution validates. - Theme Inheritance Patterns — covers how base styles and child overrides must each pass the schema and expression linting stages described here.
- Attribute Filtering Rules — dropping attributes in Tippecanoe at tile-generation time creates the null-propagation risks the expression linter is designed to catch.
- Structuring MapLibre Styles for Multi-Source Tiles — multi-source styles require dependency resolution to run against each source’s TileJSON endpoint independently.