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:

Base, theme and environment, merged in that orderA base style carries structure and layer order, a theme layer overrides colour tokens only, an environment layer overrides URLs only, and the compiler merges the three into each deployed style.THREE TIERSBase stylenever deployedEvery layer, in order, with every non-colourproperty. The single source of structure.Theme overrideslight / darkColour tokens only. If a theme file names alayer type or a filter, the split is wrong.Environment overridesdev / staging / prodTile URLs, sprite and glyph hosts,attribution. No visual difference at all.Compiled outputdeployedOne flat style per theme per environment,content-hashed so it can be cached forever.
Only the base is allowed to define structure. A theme that adds or reorders layers has stopped being a theme and started being a fork.

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

What the compiler does to produce one deployable styleThe base style is loaded, theme tokens are deep-merged over it, environment values replace URLs, the result is validated, and it is written out under a content hash.COMPILELoad basestructure + orderMerge themecolour tokensApply environmenturls onlyValidateschema + contrastboth themes, every timeHash + writestyle.<hash>.json
Compilation is deterministic: the same three inputs always produce the same hash, which is what makes the output safe to mark immutable.

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.

Why Forking Is the Failure Mode

Almost every team arrives at theme inheritance after trying the obvious thing first: copy the style, recolour the copy, ship both. It works for about two releases. The failure is not dramatic — nothing breaks — it is that the two files stop describing the same map.

The drift is always in the same direction. A layer is added to the light style during a feature change and not to the dark one, so dark-mode readers silently lose a layer. A zoom range is corrected in one file. A filter is tightened in the other. Six months later the two styles differ in forty places, of which perhaps six are intentional colour differences and the rest are accidents nobody can now distinguish from decisions.

Inheritance removes the possibility rather than the temptation. When structure exists in exactly one file, adding a layer adds it to every theme by construction, and the only thing a theme file can say is which colour a property takes. That constraint is worth enforcing mechanically: a compiler that rejects a theme override touching anything but a colour property will catch the first accidental structural override on the day it is written, rather than at the next visual review.

Testing Both Themes Rather Than One

A compiled output is only as good as the check that runs against it, and theme compilation introduces a specific risk: the light theme is the one everybody looks at. The dark variant is compiled from the same base and is therefore structurally correct, which makes it easy to assume it is visually correct too. It usually is not, because contrast is a property of a colour pair, and a palette that works against a pale background rarely inverts cleanly.

The pipeline should therefore validate every compiled output, not the base and one variant:

  • Schema validation on each compiled style. Cheap, and catches an override that introduced a malformed value.
  • Contrast assertions on each theme’s label-and-background pairs. Every text colour against every fill it can sit above, which for a basemap is a small matrix and an entirely mechanical check.
  • A rendered screenshot per theme at two or three zooms. The only check that catches a layer that is technically visible and practically invisible, such as a road casing that has become the same value as the road it outlines.

Running all three on every compiled variant is what makes adding a fourth theme a non-event. Running them only on the theme the team looks at daily is what makes the second theme drift into a liability.

Optimization Knobs

Which file a property belongs inSix style properties assigned to the base, theme or environment tier, with the reason each belongs there.WHAT GOES WHEREbelongs inbecauseLayer orderBaseIdentical in everythemefill-colorThemeThe definition of athemeline-widthBaseGeometry, not colourSource urlEnvironmentDiffers per deployment,not per themetext-halo-colorThemeContrast depends on thebackgroundLayer filterBaseSame features in boththemes
The test for any property: does it differ between light and dark? If not, it belongs in the base, however tempting it is to copy it into both themes.
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

FAQ

Why not just keep two style files?

Because they drift. A layer added to one and not the other, a zoom range corrected in one — after six months the two differ in dozens of places and nobody can tell which differences were intended.

What may a theme file contain?

Colour properties only: paint colours, halos, the background layer. If a theme file names a layer type, a filter or a zoom range, the split is wrong and the themes will diverge.

How do I stop structural overrides creeping in?

Enforce it in the compiler. A build that rejects a theme override touching anything but a colour catches the first accidental structural change on the day it is written.

Do I need to validate every compiled variant?

Yes. They are structurally identical by construction and visually different by design, and contrast is a property of a colour pair — so the dark variant needs its own check.

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 Next reading Generating Style Variants from a Design Token File