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.
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 -sqlor PostGISALTER TABLE … ALTER COLUMN … TYPEto enforce column types before ingestion. - Attribute allowlisting configured in your tile builder:
tippecanoestrips 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-Controlheaders — needed for the versioned-endpoint strategy in Step 4.
Core Concept: MapLibre Expression Operators
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.
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.
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:
{
"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.
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:
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:
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.
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.
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
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:
# 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.
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"]:
["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:
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.
Pages in This Section
- Binding Data-Driven Properties to Vector Layers — attribute retention strategies for
tippecanoe(-yallowlist) andST_AsMVTSQL pipelines, with patterns forfeature-statevs. static attribute binding and interactivity-driven style updates. - Data-Driven Color Ramps with Interpolate Expressions — building choropleth and heat ramps with
["interpolate"]and["step"]bound to a tile attribute, including breakpoints and null/NaN fallbacks.
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.