Theme Inheritance Patterns for Vector Tile Map Styling

Maintaining multiple map variants — dark mode, high-contrast, regional palettes, seasonal campaigns — becomes unmanageable when each style is authored from scratch. Theme inheritance solves this by establishing a three-tier hierarchy: an immutable base style, environment-specific override files, and runtime attribute expressions. Every compiled variant shares one source of truth, so a color palette change propagates to all variants through a single CI run.

This technique sits inside the broader Map Styling & Layer Synchronization workflow and pairs tightly with Dynamic Attribute Mapping for runtime bindings and with Style Validation Workflows for pre-deployment correctness checks.

Prerequisites

Requirement Minimum version / state
Python 3.9+
deepmerge 1.1+
pydantic 2.x
jsonschema 4.x
Base style Schema-valid MapLibre GL v8 JSON
Tile pipeline Consistent source-layer names across all target tilesets
CI/CD Artifact store (S3, GCS, or Cloudflare R2) with versioned URL support

The tile generation pipeline — Tippecanoe, Martin, or a PostGIS exporter — must emit stable layer names and attribute keys. A source-layer rename mid-pipeline silently breaks every override file referencing that layer.

Core Concept: Three-Tier Hierarchy

The three tiers map to distinct responsibilities:

Tier File Deployed directly? Key content
Base base.style.json Never Color palette, typography, layer ordering, fallback expressions
Environment theme.dark.json, theme.hc.json, theme.eu.json Only after merge Paint/layout deltas, visibility toggles, sprite swaps
Runtime Generated ["interpolate"] / ["match"] expressions Injected at compile time Attribute-driven paint values from Dynamic Attribute Mapping

The base style is the compilation anchor; it never ships to a CDN endpoint in raw form. Environment overrides must be minimal — containing only the keys that differ from the base. Runtime expressions bridge the static compilation output with live tile attributes.

The diagram below shows how the three tiers feed the CI compiler and produce independently versioned CDN artifacts:

Theme inheritance compilation flow Base style and environment overrides feed the deep-merge compiler, which validates and publishes versioned style artifacts to a CDN. Each compiled output receives a content-hash URL. base.style.json immutable root theme.dark.json theme.hc.json theme.eu.json Deep-merge + Validate CI step dark-{sha256}.json CDN / R2 / S3 hc-{sha256}.json CDN / R2 / S3 eu-{sha256}.json CDN / R2 / S3

Step-by-Step Implementation

Step 1: Author the base style

The base style contains globally applicable defaults only. Keep it lean — every key here must make sense for all variants.

json
{
  "version": 8,
  "name": "base",
  "metadata": {
    "pipeline:role": "base",
    "pipeline:version": "1.0.0"
  },
  "glyphs": "https://cdn.example.com/fonts/{fontstack}/{range}.pbf",
  "sprite": "https://cdn.example.com/sprites/default",
  "sources": {
    "openmaptiles": {
      "type": "vector",
      "tiles": ["https://tiles.example.com/{z}/{x}/{y}.pbf"],
      "minzoom": 0,
      "maxzoom": 14
    }
  },
  "layers": [
    {
      "id": "background",
      "type": "background",
      "paint": { "background-color": "#f8f4f0" }
    },
    {
      "id": "water",
      "type": "fill",
      "source": "openmaptiles",
      "source-layer": "water",
      "paint": { "fill-color": "#a8d5e2" }
    },
    {
      "id": "road-primary",
      "type": "line",
      "source": "openmaptiles",
      "source-layer": "transportation",
      "filter": ["==", ["get", "class"], "primary"],
      "paint": { "line-color": "#e8c87a", "line-width": 2 }
    }
  ]
}

Verify: jsonschema-validate the file against the MapLibre v8 spec before committing. The MapLibre GL JSON Structure reference documents every required key.

Step 2: Write minimal environment overrides

Override files contain only the keys that differ. Arrays (like layers) use a by-id merge strategy in the compiler, so you only need to include layers whose paint/layout properties change.

json
// theme.dark.json
{
  "name": "dark",
  "metadata": { "pipeline:role": "override", "pipeline:variant": "dark" },
  "layers": [
    {
      "id": "background",
      "type": "background",
      "paint": { "background-color": "#1a1a2e" }
    },
    {
      "id": "water",
      "type": "fill",
      "source": "openmaptiles",
      "source-layer": "water",
      "paint": { "fill-color": "#16213e" }
    },
    {
      "id": "road-primary",
      "type": "line",
      "source": "openmaptiles",
      "source-layer": "transportation",
      "filter": ["==", ["get", "class"], "primary"],
      "paint": { "line-color": "#e94560", "line-width": 2 }
    }
  ]
}

Verify: The override file must be significantly smaller than the base. If it is approaching the same size, the base is not lean enough.

Step 3: Build the deep-merge compiler

Naive dict.update() flattens nested objects and reorders layers by positional index. Use a schema-aware merge that reconciles layers arrays by id field.

python
import json
import hashlib
from pathlib import Path
from typing import Any

def merge_layers(base_layers: list, override_layers: list) -> list:
    """Merge layers arrays by 'id', preserving base ordering."""
    index = {layer["id"]: layer.copy() for layer in base_layers}
    for ol in override_layers:
        lid = ol.get("id")
        if lid in index:
            # Deep-merge paint and layout sub-dicts; scalar keys overwrite
            for key in ("paint", "layout"):
                if key in ol:
                    index[lid].setdefault(key, {}).update(ol[key])
            # Non-dict keys overwrite directly
            for key, val in ol.items():
                if key not in ("paint", "layout"):
                    index[lid][key] = val
        else:
            # Append new layers from override at the end
            index[lid] = ol.copy()
    # Preserve base ordering, then append override-only layers
    base_ids = [l["id"] for l in base_layers]
    extra = [v for k, v in index.items() if k not in base_ids]
    return [index[lid] for lid in base_ids] + extra


def deep_merge(base: dict, override: dict) -> dict:
    """Recursively merge override into base; arrays use by-id strategy for layers."""
    result = base.copy()
    for key, val in override.items():
        if key == "layers" and key in result:
            result[key] = merge_layers(result[key], val)
        elif isinstance(val, dict) and isinstance(result.get(key), dict):
            result[key] = deep_merge(result[key], val)
        else:
            result[key] = val
    return result


def compile_theme(
    base_path: Path,
    override_path: Path,
    output_dir: Path,
) -> Path:
    base_style = json.loads(base_path.read_text())
    override_style = json.loads(override_path.read_text())

    merged = deep_merge(base_style, override_style)

    # Deterministic content hash for cache-busting
    content_hash = hashlib.sha256(
        json.dumps(merged, sort_keys=True).encode()
    ).hexdigest()[:12]

    variant = override_style.get("name", override_path.stem)
    out_path = output_dir / f"{variant}-{content_hash}.json"
    output_dir.mkdir(parents=True, exist_ok=True)
    out_path.write_text(json.dumps(merged, indent=2, sort_keys=True))
    return out_path

Verify: Run a diff between two consecutive compiled outputs for the same variant. If the content hash changes without any source file changing, the merge is non-deterministic.

Step 4: Validate every compiled output

Run two validation passes before the artifact leaves CI:

  1. Schema compliance against the MapLibre v8 spec JSON
  2. Expression linting — verify every ["get", attr] reference exists in your tile schema
python
import jsonschema
import requests

_SCHEMA_URL = (
    "https://raw.githubusercontent.com/maplibre/maplibre-style-spec"
    "/main/src/reference/v8.json"
)

def validate_style(style_path: Path, schema_cache_path: Path) -> None:
    """Raises jsonschema.ValidationError on any spec violation."""
    if not schema_cache_path.exists():
        schema_cache_path.write_text(requests.get(_SCHEMA_URL, timeout=10).text)

    schema = json.loads(schema_cache_path.read_text())
    style = json.loads(style_path.read_text())
    jsonschema.validate(instance=style, schema=schema)


def audit_get_refs(style: dict, known_attrs: set[str]) -> list[str]:
    """Return a list of attribute names referenced via ['get', ...] with no tile backing."""
    missing = []

    def walk(node: Any) -> None:
        if isinstance(node, list) and len(node) >= 2 and node[0] == "get":
            attr = node[1]
            if isinstance(attr, str) and attr not in known_attrs:
                missing.append(attr)
        elif isinstance(node, dict):
            for v in node.values():
                walk(v)
        elif isinstance(node, list):
            for item in node:
                walk(item)

    walk(style)
    return missing

The Style Validation Workflows guide covers plugging this step into GitHub Actions with schema caching to avoid network fetches on every CI run.

Step 5: Publish with content-hash URLs and correct headers

bash
# Compile all variants
python3 compile_themes.py \
  --base themes/base.style.json \
  --overrides themes/theme.*.json \
  --output dist/styles/

# Upload to Cloudflare R2 (or S3)
for f in dist/styles/*.json; do
  aws s3 cp "$f" "s3://example-tiles/styles/$(basename $f)" \
    --content-type "application/json" \
    --cache-control "public, max-age=31536000, immutable"
done

# Update the short-lived manifest that clients poll
aws s3 cp dist/styles/manifest.json s3://example-tiles/styles/manifest.json \
  --content-type "application/json" \
  --cache-control "public, max-age=60, must-revalidate"

Verify: Fetch the manifest URL and confirm the ETag changes after an upload. Request any compiled style artifact and confirm the Cache-Control header includes immutable.

Optimization Knobs

Parameter Conservative Aggressive Trade-off
Override depth Base → Environment (2 levels) Base → Region → Campaign (3 levels) Deeper trees are harder to debug; merge time is negligible
Array merge strategy By id (recommended) Positional index Positional scrambles layer z-order on any insertion
Manifest TTL 60 s 300 s Lower TTL catches style rollouts faster; slightly higher CDN origin load
Schema cache TTL in CI Pin to git-tracked local copy Fetch from spec repo HEAD Live fetch risks breaking builds on spec repo downtime

Integration with Adjacent Pipeline Stages

Compiled theme artifacts slot into the broader pipeline at two points:

Upstream — tile generation: The source-layer names in override files must match the layer names emitted by Tippecanoe or Martin. If the tile pipeline uses --layer=transport but the style references source-layer: transportation, every road layer silently disappears. Coordinate renames through the same CI commit that updates the base style.

Downstream — CDN and tile server: Theme JSON files are static blobs. Serve them from the same CDN origin as .pmtiles or .mbtiles tile files so that a single CDN rule covers both. When updating MBTiles containers or converting to PMTiles for HTTP range-request delivery, increment both the tile URL version and the theme manifest in the same deployment. A mismatched source URL in the compiled style causes the renderer to request tiles from a stale or deleted endpoint.

Troubleshooting

Symptom Diagnosis Fix
Layer disappears after dark-mode override source-layer mismatch between compiled style and tile payload grep -r '"source-layer"' dist/styles/dark-*.json — compare against tippecanoe --layer= values
Layer z-order scrambles after adding a new override Positional array merge used instead of by-id merge Switch to merge_layers() shown in Step 3; re-run compile
Compiled hash differs across identical runs Non-deterministic key ordering in json.dumps Pass sort_keys=True to every json.dumps call in the pipeline
Schema validation passes locally but fails in CI Different jsonschema versions or cached schema mismatch Pin jsonschema==4.21.1 in requirements.txt; commit the cached schema JSON
Stale style served to browser after deployment CDN serving cached manifest or Cache-Control: immutable on manifest endpoint Set max-age=60, must-revalidate on manifest.json; verify with curl -I

Further Reading

Compiling Dark-Mode Tile Styles from a Base — generating light and dark variants from one base style by overriding only paint colors, with an assertion that every variant keeps identical layer ids and source-layer names so all stay schema-synchronized.

  • MapLibre GL JSON Structure — the underlying spec that every compiled theme must satisfy, including layer ordering rules and expression syntax.
  • Dynamic Attribute Mapping — generate runtime ["interpolate"] and ["match"] expressions injected at the third inheritance tier.
  • Style Validation Workflows — CI pipeline steps for schema compliance, expression linting, and visual regression testing of compiled themes.

Parent: Map Styling & Layer Synchronization

Next reading Compiling Dark-Mode Tile Styles from a Base