Dynamic Attribute Mapping for Vector Tile Pipelines

Binding tile feature properties to rendered visual styles at the right pipeline stage determines whether your maps adapt to data changes without rebuilding tiles. This page covers how to structure the attribute-to-expression translation layer — from schema normalization and expression generation through cache invalidation and runtime validation — so that cartographic outputs stay synchronized with upstream data without sacrificing tile cache efficiency.

The path an attribute takes from source column to rendered colourA source column is normalised, carried into the tile by an include flag, decoded into a feature property, read by a get expression and mapped to a paint value.ATTRIBUTE TO PIXELSource columnpopulation: intNormalisecast, fill nullsthe step that fixes typesInto the tile--include populationget expression["get", "population"]Paint valuefill-color
Five hops, and the type must survive all of them. A number that becomes a string anywhere in the chain silently falls through to the default paint.

Prerequisites

Before implementing dynamic attribute mapping, confirm the following are in place:

  • Consistent source schema: Attribute names and data types must be stable across pipeline runs. Use ogr2ogr -sql or PostGIS ALTER TABLE … ALTER COLUMN … TYPE to enforce column types before ingestion.
  • Attribute allowlisting configured in your tile builder: tippecanoe strips unknown attributes by default when you use -y/--include. If you rely on dropping unused attributes to reduce tile size, every attribute referenced in your expressions must appear in the allowlist.
  • MapLibre Style Specification familiarity: The MapLibre GL JSON structure defines the expression language. Know the difference between ["get", …], ["feature-state", …], and ["zoom"] before authoring expressions.
  • Python 3.10+, pyogrio/geopandas: Required for the normalization and expression-generation scripts below.
  • A caching layer: Redis, Cloudflare R2, or S3 with Cache-Control headers — needed for the versioned-endpoint strategy in Step 4.

Core Concept: MapLibre Expression Operators

Which expression operator fits which attribute shapeFour operators compared on the attribute type they suit, what the legend looks like, and the failure mode when they are misapplied.OPERATORSattribute islegend reads asmisapplied givesinterpolateContinuous numericA gradientFlat colour on stringsstepNumeric, binnedDiscrete bandsWrong bin at the edgesmatchCategoricalA keyFallback for newcategoriescaseBoolean logicHard to draw at allUnreadable rules
The legend column is the practical test: if you cannot describe the legend in one sentence, the operator is probably wrong.

Three expression operators handle the majority of attribute-driven styling. Choose based on your attribute’s distribution:

Operator Input type Typical use Tile size impact
["interpolate", ["linear"], ["get", attr], …] Continuous numeric Population density → colour ramp, elevation → line width Minimal — expression lives in style JSON, not tile payload
["match", ["get", attr], cat1, val1, …, fallback] Categorical string Land use class → fill colour, road class → dash pattern Minimal — same as above
["step", ["get", attr], default, stop1, val1, …] Stepped numeric Speed limit → icon scale, floor count → extrusion height Minimal
["coalesce", ["get", attr], fallback] Any nullable Null-safe wrapper around any of the above Minimal

All four operators evaluate per-feature inside the renderer against the feature’s attribute dictionary embedded in the .pbf tile. Geometry is static in the cache; only the style JSON changes when you update visual mappings. This is the architectural advantage of keeping expressions in the style document rather than baking derived properties into tile attributes.

Step-by-Step Implementation

Step 1: Normalize Attributes Before Tiling

Raw geospatial data has inconsistent casing, stray whitespace, and mixed numeric types. Normalize before passing data to tippecanoe — downstream expression evaluation failures are much harder to diagnose than upstream type errors.

python
import geopandas as gpd
import pandas as pd

def normalize_attributes(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """
    Coerce attribute types and fill nulls before tile generation.
    Returns a copy — does not mutate the input GeoDataFrame.
    """
    gdf = gdf.copy()

    # Standardize categorical strings: strip, lowercase
    cat_cols = gdf.select_dtypes(include=["object"]).columns.tolist()
    for col in cat_cols:
        gdf[col] = gdf[col].str.strip().str.lower().fillna("unknown")

    # Coerce numeric fields; invalid strings become NaN, then a sentinel
    NUMERIC_ATTRS = ["population", "elevation_m", "traffic_index", "floor_count"]
    for col in NUMERIC_ATTRS:
        if col in gdf.columns:
            gdf[col] = pd.to_numeric(gdf[col], errors="coerce").fillna(-9999)

    return gdf

# Verify: no NaNs remain in rendering-critical columns
gdf = normalize_attributes(gpd.read_file("input.geojson"))
assert gdf[["population", "elevation_m"]].isna().sum().sum() == 0, "Nulls remain after normalization"

What to verify: Confirm that sentinel values (-9999) appear in your ["step"] or ["match"] expressions as an explicit fallback case rather than being silently styled with the default.

Step 2: Generate MapLibre Expressions Programmatically

Build style expressions in code rather than hand-editing style JSON. This makes expression logic version-controllable and testable.

python
import json
from typing import Any

def build_interpolate_expr(attr: str, stops: list[tuple[float, str]]) -> list:
    """
    Continuous numeric → colour ramp.
    stops: [(data_value, css_colour), ...]
    """
    expr: list[Any] = ["interpolate", ["linear"], ["get", attr]]
    for value, colour in sorted(stops):
        expr.extend([value, colour])
    return expr

def build_match_expr(attr: str, mapping: dict[str, str], fallback: str) -> list:
    """
    Categorical string → discrete value (colour, dash array, etc.).
    mapping: {"category_value": "output_value", ...}
    """
    expr: list[Any] = ["match", ["get", attr]]
    for category, output in mapping.items():
        expr.extend([category, output])
    expr.append(fallback)
    return expr

def build_null_safe(attr: str, inner_expr: list, fallback: str) -> list:
    """Wrap any expression to handle null/missing attributes gracefully."""
    return ["coalesce", inner_expr, fallback]

# Population density colour ramp (people / km²)
pop_colour = build_interpolate_expr(
    attr="population",
    stops=[(0, "#f7fbff"), (500, "#9ecae1"), (2000, "#3182bd"), (10000, "#08306b")]
)

# Land use category to fill colour
landuse_colour = build_match_expr(
    attr="landuse",
    mapping={
        "residential": "#f5e6cc",
        "commercial":  "#ffd699",
        "industrial":  "#c9c9c9",
        "park":        "#aad4a0",
    },
    fallback="#e8e8e8"
)

print(json.dumps(pop_colour, indent=2))

Expressions attach to MapLibre GL JSON structure layer paint or layout properties. A fill-color using pop_colour would appear as:

json
{
  "id": "population-fill",
  "type": "fill",
  "source": "census",
  "source-layer": "districts",
  "paint": {
    "fill-color": ["interpolate", ["linear"], ["get", "population"],
      0, "#f7fbff", 500, "#9ecae1", 2000, "#3182bd", 10000, "#08306b"],
    "fill-opacity": 0.8
  }
}

Step 3: Encode Tiles with Attribute Type Declarations

Pass explicit type hints to tippecanoe using --attribute-type (-T). This prevents numeric fields from being coerced to strings by the MVT encoder when input GeoJSON serializes them ambiguously.

bash
tippecanoe \
  --output=output.mbtiles \
  --layer=districts \
  --maximum-zoom=14 \
  --minimum-zoom=4 \
  --drop-densest-as-needed \
  --coalesce-densest-as-needed \
  --attribute-type=population:int \
  --attribute-type=elevation_m:float \
  --attribute-type=landuse:string \
  --attribute-type=floor_count:int \
  --include=population \
  --include=elevation_m \
  --include=landuse \
  --include=floor_count \
  input_normalized.geojson

When using a dynamic tile server such as martin or pg_tileserv, tile attributes are embedded at query time rather than at build time — the same normalization discipline applies, but you enforce types in the PostGIS column definition rather than via --attribute-type. See Binding Data-Driven Properties to Vector Layers for the ST_AsMVT SQL pattern.

What to verify: Run tippecanoe-decode output.mbtiles 8 72 96 | jq '.features[0].properties' on a sample tile to confirm types. Numeric attributes must appear as JSON numbers, not quoted strings.

Step 4: Versioned Cache Keys and Invalidation

Dynamic attribute mapping introduces a cache coherence problem: if the attribute schema or expression logic changes but tiles are cached indefinitely, clients render stale visuals. Resolve this with two coordinated strategies:

Strategy A — schema-versioned tile endpoints. Hash the attribute schema and embed it in the tile URL path:

python
import hashlib
import geopandas as gpd

def schema_hash(gdf: gpd.GeoDataFrame, length: int = 8) -> str:
    """Deterministic hash of attribute names and dtypes."""
    schema_repr = str(sorted((col, str(dtype)) for col, dtype in gdf.dtypes.items()))
    return hashlib.sha256(schema_repr.encode()).hexdigest()[:length]

# Tile URL pattern: /tiles/v{schema_hash}/{z}/{x}/{y}.pbf
gdf = gpd.read_file("input_normalized.geojson")
version = schema_hash(gdf)           # e.g. "a3f8b19c"
tile_endpoint = f"/tiles/v{version}///.pbf"

Rotate the version string whenever attribute names, types, or the expression set changes. The CDN treats the new path as a cache miss and fetches fresh tiles.

Strategy B — stale-while-revalidate headers. For low-churn datasets where stale visuals for a short window are acceptable:

text
Cache-Control: public, max-age=3600, stale-while-revalidate=86400

This serves cached tiles immediately while revalidating asynchronously in the background. Pair with Theme Inheritance Patterns so the renderer falls back gracefully to a base theme when a property fails to resolve — rather than rendering unstyled geometry.

Step 5: Validate Expressions Before Deployment

Expression failures surface as silent rendering gaps in production. Validate before deploying the style JSON.

python
from typing import Any

VALID_OPS = {"interpolate", "match", "step", "coalesce", "case", "get",
             "feature-state", "zoom", "heatmap-density", "line-progress"}

def validate_expression(expr: Any, available_attrs: set[str]) -> list[str]:
    """
    Static validation for a MapLibre-style expression.
    Returns a list of error strings (empty = valid).
    """
    errors: list[str] = []
    if not isinstance(expr, list) or len(expr) < 1:
        return ["Expression must be a non-empty list"]

    op = expr[0]
    if op not in VALID_OPS:
        errors.append(f"Unknown operator: '{op}'")
        return errors

    # Check ["get", attr] references
    if op == "get":
        if len(expr) < 2:
            errors.append("'get' requires an attribute name")
        elif expr[1] not in available_attrs:
            errors.append(f"Attribute '{expr[1]}' not in tile schema: {sorted(available_attrs)}")
        return errors

    # For compound operators, recurse into nested expressions
    for child in expr[1:]:
        if isinstance(child, list):
            errors.extend(validate_expression(child, available_attrs))

    return errors

# Integration gate — run before each style deployment
sample_attrs = {"population", "elevation_m", "landuse", "floor_count"}
errs = validate_expression(pop_colour, sample_attrs)
assert not errs, f"Expression validation failed: {errs}"

errs = validate_expression(landuse_colour, sample_attrs)
assert not errs, f"Expression validation failed: {errs}"

For comprehensive pre-deployment testing, pair static validation with headless browser rendering using Playwright against a local martin tile server. A render regression is caught far more cheaply at CI time than in production.

Normalise Before Tiling, Not in the Expression

An expression can compensate for messy data — to-number around a string, coalesce around a null, downcase around inconsistent casing — and every one of those compensations is paid per feature, per frame, on every reader’s device. The same fix applied once at build time is free at runtime and, more importantly, makes the tileset honest about its own types.

The rule that follows is worth stating plainly: an expression should express a design decision, never a data repair. interpolate over a population figure is a design decision. to-number around that population figure is a repair, and it belongs in the pipeline that produced the tile.

Optimization Knobs

Parameter Lower setting Higher setting Primary trade-off
--maximum-zoom 10–12 14–16 Lower → smaller tile count, less attribute resolution at street level
--simplification / -S 2–4 8–16 Higher → fewer vertices per tile, faster render, less geometry fidelity
Expression complexity (number of stops) 3–5 stops 20+ stops More stops → finer visual gradation, marginally slower per-feature evaluation
Attribute count per feature 3–5 attributes 15+ attributes More attributes → larger .pbf payload, slower client parsing at z14+

Keep the attribute count per feature to the minimum required by your active expression set. Every retained attribute inflates every tile that contains features from that layer — see Attribute Filtering Rules for the audit-and-allowlist workflow.

Integration with Adjacent Pipeline Stages

Dynamic attribute mapping sits between two adjacent stages:

Upstream — tile generation: Attribute normalization (Step 1) and --attribute-type declarations (Step 3) must complete before tippecanoe encodes the .mbtiles output. If you use GeoParquet input processing to pipe .geoparquet files into tippecanoe via stdin, apply normalization on the Arrow record batches before serializing to NDJSON — this avoids a full materialization step.

Downstream — style deployment: The generated expressions (Step 2) attach to the MapLibre GL JSON structure and are deployed to the CDN independently of the tile files. A style update — changing a colour ramp or adding a new categorical mapping — requires no tile regeneration. The style JSON is small (typically under 100 KB) and can be deployed with a short max-age (60–300 seconds) while tiles carry long TTLs (hours to days). This decoupling is the core performance advantage of the data-driven expression model.

Style validation before deployment is covered in Style Validation Workflows.

Troubleshooting

Numeric attributes rendering as the fallback colour

The three ways an expression ends up on the fallback valueAn attribute can be absent from the tile, present as the wrong type, or present as null; each path ends at the layer's default paint value rather than an error.WHY IT FALLS THROUGHAbsentfiltered out at buildWrong typestring, not numberFallback paintsilentlyget returns nullinterpolate cannot coerce
None of these throws. The map renders, in the wrong colour, which is why coalesce with an explicit default beats relying on the fallback.

Symptom: All features use the fallback colour in a ["match"] or the minimum stop in ["interpolate"], even though the attribute is present in the source data.

Diagnosis:

bash
# Decode a tile and inspect raw property types
tippecanoe-decode output.mbtiles 10 512 341 | \
  jq '[.features[].properties | to_entries[] | select(.key == "population")]'

If values appear as strings ("1250" rather than 1250), the MVT encoder serialized numerics as strings. Fix: Add --attribute-type=population:int to the tippecanoe invocation and re-encode.

Missing attributes in decoded tile features

Symptom: tippecanoe-decode shows features with no population or landuse key.

Diagnosis: Run tippecanoe with --read-parallel disabled and check stderr for dropping attribute warnings. If attributes are absent, either the allowlist is too restrictive or the source GeoJSON field names differ from what you passed to --include.

bash
tippecanoe … --include=population 2>&1 | grep -i "drop\|skip\|attribute"

Fix: Confirm field names match exactly (case-sensitive): ogr2ogr -al input.geojson /vsistdout/ | head -5 | python3 -m json.tool | jq '.features[0].properties | keys'

Expression evaluation silently returns null

Symptom: A ["get", "landuse"] expression returns null for some features, which causes the renderer to skip the feature or apply a default style.

Diagnosis: The attribute is present in most features but absent from some (sparse attribute coverage is allowed by MVT). Wrap every ["get"] with ["coalesce"]:

json
["coalesce", ["get", "landuse"], "unknown"]

Then ensure "unknown" is one of the ["match"] cases or that your ["interpolate"] fallback handles the sentinel.

Cache not invalidating after expression update

Symptom: Clients continue to see the old colour ramp after you deploy a new style JSON.

Diagnosis: The style URL is cached at the CDN or browser layer. Check the Cache-Control header on the style endpoint:

bash
curl -sI https://tiles.example.com/styles/main.json | grep -i cache-control

Fix: Either deploy the style to a versioned URL (e.g. /styles/v2/main.json) or set a short max-age (60 s) on the style endpoint while keeping a long max-age on tile .pbf responses.

interpolate expression produces flat colour at all zoom levels

Symptom: The ["interpolate", ["linear"], ["zoom"], …] expression appears correct but renders the same colour regardless of zoom.

Cause: The expression references ["zoom"] but the layer’s paint property wraps it inside a data expression that already uses ["get", attr]. MapLibre does not support mixing zoom-dependent and feature-data expressions in a single interpolate unless you use ["interpolate", ["linear"], ["zoom"], …] at the top level with feature expressions only in the stop values.

Fix: Split the zoom interpolation and feature interpolation into separate nested ["step"] or ["interpolate"] calls, or use ["case"] to branch on zoom ranges first.

FAQ

Why does my data-driven colour fall back to the default?

Three possible reasons, all silent: the attribute was filtered out of the tile, its type is wrong, or its value is null. None of them raises an error, so the layer simply renders in its fallback paint.

Should type coercion live in the expression or the pipeline?

The pipeline. An expression should express a design decision; to-number around a value is a data repair, and it costs per feature per frame on every reader’s device.

Which operator suits which attribute?

interpolate for continuous numbers, step for binned numbers, match for categories, case for boolean logic. If you cannot describe the legend in one sentence, the operator is probably wrong.

How expensive are expressions?

A constant is free. A simple get into match costs about a millisecond per ten thousand features; a nested case over feature state costs ten times that, re-evaluated whenever state changes.

Pages in This Section


Parent: Map Styling & Layer Synchronization

Related

  • MapLibre GL JSON Structure — the full specification for sources, layers, and paint/layout properties that host the expressions built on this page.
  • Attribute Filtering Rules — the upstream companion: deciding which attributes to keep in tiles before the expression binding layer sees them.
  • Style Validation Workflows — automated schema and expression validation to run as a CI gate before deploying updated style JSON.
  • Theme Inheritance Patterns — fallback style architecture that gracefully degrades when dynamic expressions fail to resolve.
Next reading Binding Data-Driven Properties to Vector Layers Next reading Data-Driven Color Ramps with Interpolate Expressions