Layer Filter Synchronization with Tile Attributes
A MapLibre layers[].filter that references a renamed source-layer or a dropped attribute does not throw — it silently matches zero features, and the layer simply renders nothing while the tiles load with HTTP 200. This guide treats the filter as a contract against the tile schema: how to enumerate every property key and layer name a filter depends on, diff those references against the schema your pipeline actually emitted, and fail the build before a blank map reaches production.
Prerequisites
| Requirement | What it provides | Notes |
|---|---|---|
Tile schema manifest (tile_schema.json) |
Authoritative list of layer names + field keys per layer | Extracted from tileset metadata after generation |
style.json |
The MapLibre GL document under test | The artifact whose filters are validated |
@maplibre/maplibre-gl-style-spec |
Reference expression parser + validateStyleMin |
npm package; supplies canonical operator list and migration helpers |
| MVT layer names | The actual layer IDs inside the .pbf/.pmtiles |
From tippecanoe --layer or the input basename |
| Python 3.10+ | Runs the diff and sampled-decode checks | pyarrow, mapbox-vector-tile for decode verification |
pmtiles / tippecanoe-json-tool |
Reads vector_layers metadata |
Either CLI resolves layer IDs and field lists |
The tile schema manifest is the single source of truth this whole guide diffs against. If you do not already produce one, the extraction pattern in MapLibre GL JSON structure and the parent Map Styling & Layer Synchronization overview show how to derive it from tileset metadata as the first CI step after tile generation.
Core Concept: The MapLibre Filter and Expression Model
A filter decides, per feature, whether a layer draws it. MapLibre accepts two syntaxes, and a synchronization checker must understand both because production styles mix them freely.
| Form | Example | Reads from tile | Notes |
|---|---|---|---|
| Legacy filter array | ["==", "class", "primary"] |
property class (implicit get) |
The property key is the second element; no ["get", …] wrapper |
Expression == |
["==", ["get", "class"], "primary"] |
property class |
Modern syntax; the key is inside a nested ["get", …] |
["in", …] |
["in", ["get", "class"], ["literal", ["primary","trunk"]]] |
property class |
Set membership; legacy form is ["in", "class", "primary", "trunk"] |
["match", …] |
["match", ["get", "class"], "primary", true, false] |
property class |
Used both as filter and as paint value selector |
["has", …] |
["has", "class"] |
key existence of class |
Passes even when the value is null, so long as the key exists |
["feature-state", …] |
["==", ["feature-state", "hover"], true] |
not the tile | Reads runtime state, never the tile schema — must be excluded from schema diffs |
Two categories of reference must resolve against the tile schema for a filter to ever match:
source-layer— thelayers[].source-layervalue must equal a layer ID present in the tile. This is not part of the filter array itself, but a wrongsource-layerproduces the identical symptom (zero features), so a checker must validate both together.- Property keys — every
["get", KEY](and every legacy bare-key position) must name a field that survived into the tile. Attributes stripped by attribute filtering upstream are the most common cause of a key that exists in the source data but not in the tile.
A ["feature-state", …] reference is the important exception: it is resolved from runtime interaction state, not the tile payload, so it must be skipped by the diff or it will produce false failures.
Why a broken filter is silent
MapLibre evaluates a filter as a boolean predicate per feature. When the predicate references a key the feature does not carry, ["get", KEY] returns null rather than raising, and null compared with any value under == is simply false — so the feature is excluded. Multiply that across every feature in the layer and the layer draws nothing, with no console error and no failed network request. A wrong source-layer is even quieter: the renderer looks for a layer of that name inside each decoded tile, finds none, and treats the layer as having zero features. Neither failure trips the style-spec validator either, because both the property key and the source-layer string are structurally valid — they are just names that happen not to exist in the tiles. This is precisely why synchronization has to be checked against the tile schema rather than against the style alone: the style is internally consistent in every one of these failure cases.
Combinators and nesting
Real filters nest the operators above inside ["all", …], ["any", …], and ["!", …] combinators, and inside ["case", …] and ["match", …] in paint properties. A synchronization checker cannot special-case the top-level operator; it has to walk the whole tree. A filter such as ["all", ["==", ["get", "class"], "primary"], ["!", ["has", "tunnel"]]] references two keys — class and tunnel — at different depths, and both must resolve. The recursive collector in Step 2 handles this by descending into every list element rather than inspecting only expr[1].
Step-by-Step Implementation
Step 1 — Extract the tile schema
Read the vector_layers block from the tileset metadata and flatten it into a {layer_id: {fields}} map. This is the same manifest the schema-agreement gate uses, so produce it once and reuse it.
import json
import subprocess
def extract_tile_schema(pmtiles_path: str) -> dict[str, set[str]]:
# `pmtiles show --header-json` also works; tippecanoe-json-tool for .mbtiles
raw = subprocess.check_output(["pmtiles", "show", pmtiles_path, "--metadata"])
meta = json.loads(raw)
vector_layers = meta["vector_layers"] # [{id, fields: {name: type}, ...}]
schema = {
layer["id"]: set(layer.get("fields", {}).keys())
for layer in vector_layers
}
return schema
schema = extract_tile_schema("roads.pmtiles")
# {'transportation': {'class', 'oneway', 'ref'}, 'water': {'class'}}
Verify: sorted(schema) lists exactly the layer IDs you expect. A missing layer here means either the tile was built without it or --layer renamed it — both surface downstream as filter mismatches.
Step 2 — Parse every filter and expression for references
Walk each layer’s filter, plus every value in paint and layout, collecting the source-layer and every property key. The parser must handle legacy arrays (bare key in position 1) and expression ["get", …] alike, and must skip feature-state.
def collect_refs(expr, keys: set[str]) -> None:
if not isinstance(expr, list) or not expr:
return
op = expr[0]
if op == "get" and len(expr) == 2 and isinstance(expr[1], str):
keys.add(expr[1])
return
if op == "feature-state":
return # runtime state, not a tile field
# legacy filters: ["==", "class", "primary"], ["in", "class", ...]
if op in {"==", "!=", "<", ">", "<=", ">=", "in", "!in"} \
and len(expr) >= 2 and isinstance(expr[1], str):
keys.add(expr[1])
if op == "has" and len(expr) == 2 and isinstance(expr[1], str):
keys.add(expr[1])
for item in expr[1:]:
collect_refs(item, keys)
def layer_refs(layer: dict) -> tuple[str | None, set[str]]:
keys: set[str] = set()
collect_refs(layer.get("filter", []), keys)
for block in (layer.get("paint", {}), layer.get("layout", {})):
for value in block.values():
collect_refs(value, keys)
return layer.get("source-layer"), keys
Verify: For a known layer, print the collected key set and confirm it matches what you read in the style by eye — the legacy-vs-expression branch is where parsers usually miss references. The classic omission is the legacy form ["in", "class", "primary", "trunk"], where class sits in position 1 as a bare string and the remaining elements are literal values, not nested expressions. A collector written only for ["get", …] will silently miss that key and report a clean pass on a filter that is actually broken. Test the collector against both the legacy and expression form of the same filter and confirm they yield the identical key set.
Step 3 — Diff against the schema and fail on unknown references
Every source-layer must be a schema key; every collected property key must be in that layer’s field set. Accumulate errors and exit non-zero so the CI job blocks the deploy.
import sys
def check_style(style: dict, schema: dict[str, set[str]]) -> list[str]:
errors: list[str] = []
for layer in style.get("layers", []):
src_layer, keys = layer_refs(layer)
if src_layer is None:
continue # background / raster layers have no source-layer
if src_layer not in schema:
errors.append(
f"{layer['id']}: source-layer '{src_layer}' not in tile schema "
f"{sorted(schema)}")
continue
unknown = keys - schema[src_layer]
for key in sorted(unknown):
errors.append(
f"{layer['id']}: filter/expression references '{key}' "
f"absent from layer '{src_layer}'")
return errors
style = json.load(open("style.json"))
errs = check_style(style, schema)
if errs:
print("\n".join(errs), file=sys.stderr)
sys.exit(1)
print("Filter synchronization OK")
This is the same shape as the check in style validation workflows; the difference is that here the emphasis is on filters and the source-layer join, not just paint expressions.
Two refinements make the diff robust in production. First, treat background and raster layers as legitimately having no source-layer and skip them rather than erroring — the if src_layer is None guard above does this. Second, when a source-layer is missing, do not stop at the first error; collect the full list so a single CI run reports every broken layer at once. A build that fails, gets one line fixed, fails again, and repeats wastes a full pipeline cycle per error. The accumulator pattern here surfaces all of them, and printing the valid schema keys alongside each error turns a “layer is blank” mystery into a one-line diff a reviewer can act on without decoding a tile.
Step 4 — Verify features actually match with a sampled decode
A reference can be structurally valid yet still match nothing — for example, ["==", ["get", "class"], "primary"] where the tile only ever stores class values of road and path. Structural checks cannot catch a value mismatch; only decoding a real tile can. Sample one tile per layer at a mid zoom and confirm the filter selects a non-zero count.
import gzip
import mapbox_vector_tile
from maplibre_filter import compile_filter # thin wrapper over the spec parser
def sample_matches(pbf_bytes: bytes, source_layer: str, flt) -> int:
tile = mapbox_vector_tile.decode(gzip.decompress(pbf_bytes))
layer = tile.get(source_layer, {})
predicate = compile_filter(flt)
return sum(1 for f in layer.get("features", [])
if predicate(f["properties"]))
# Pull one representative tile (z, x, y) and assert each layer draws something
count = sample_matches(open("z12_2094_1361.pbf", "rb").read(),
"transportation",
["==", ["get", "class"], "primary"])
assert count > 0, "filter matched 0 features on the sampled tile"
Verify: Run the sampled decode over two or three tiles that you know contain the feature class. A zero count on all of them signals a value-level drift (a category renamed upstream) that Step 3 cannot see.
Choose the sample tiles deliberately. A tile at the layer’s minimum zoom often has features dropped by --drop-densest-as-needed, so a zero count there can be a false alarm; pick a mid-to-high zoom tile over an area you know is dense with the feature class. For a small number of layers, hard-code one representative (z, x, y) per layer; for larger styles, derive the sample from the tileset’s bounds metadata so the check keeps working as coverage changes. Keep this decode step out of the fast pre-commit path — it needs a real tile — and run it in the same CI job that already has the built tiles on disk, right after the structural diff in Step 3 passes.
Optimization Knobs
| Choice | Cheaper option | Faster-render option | Trade-off |
|---|---|---|---|
| Category matching | ["==", ["get", "class"], "primary"] (string) |
Integer code ["==", ["get", "class_id"], 3] |
Integer compares are faster per feature and shrink the tile payload; require a code map maintained in the pipeline |
| Selection mechanism | filter (evaluated at layer level) |
feature-state (evaluated on interaction) |
feature-state avoids re-tessellation for hover/select; not a schema field, so it never appears in tiles |
| Set membership | Repeated ["==", …] in ["any", …] |
Single ["in", ["get","class"], ["literal", [...]]] |
["in"] is one traversal instead of N; keep the literal list sorted for readability |
| Index-friendliness | Filter on any attribute | Filter on the attribute Tippecanoe ordered features by | Filtering on the sort key lets the renderer skip contiguous runs; requires coordinating the sort in the build |
| Attribute footprint | Keep all categories as strings | Precompute integer category codes at ingest | Fewer distinct string values compress better; codes need a documented lookup shipped with the style |
Precomputing integer category codes is the highest-leverage knob: it shrinks the tile, speeds per-feature evaluation, and — because the code is assigned in the pipeline — makes the style filter depend on a stable numeric contract rather than free-text values that drift. The cost is that the code-to-label mapping now lives in two places, the ingest step and the style, so version them together: emit the lookup table as part of the same schema manifest the diff reads, and the checker can validate not just that class_id exists but that the literal codes a filter compares against are within the range the pipeline assigned.
The feature-state versus filter choice deserves its own note, because the two are not interchangeable. A filter is evaluated when the tile is tessellated and decides which features exist in the layer at all; a feature-state value is applied after tessellation and is meant for interaction-driven restyling (hover, selection) without a re-parse. Using feature-state in a synchronization context is a category error — it never reads the tile, so it is invisible to a schema diff and cannot be broken by an upstream rename. That is exactly why the Step 2 collector skips it: including it would produce false failures for references that, by design, do not belong to the tile schema.
Integration with Adjacent Pipeline Stages
Filter synchronization sits downstream of attribute handling and upstream of deployment.
Upstream — attribute filtering and renames from Tippecanoe. The set of keys a filter is allowed to reference is exactly the set that survives Tippecanoe’s -y/--include and --exclude flags. When attribute filtering rules drop a column, every filter that reads it goes dark. Run the extraction in Step 1 against the tiles the current build produced, never against a stale manifest, so a newly dropped attribute is caught immediately.
Adjacent — dynamic attribute mapping. Filters and data-driven paint expressions read the same ["get", KEY] references, so the checker in Step 2 covers both. See dynamic attribute mapping for how those same keys drive colour ramps and match expressions in paint.
The schema-agreement CI gate. Steps 1–3 are the filter-specific half of the schema-agreement gate described in the parent overview. Fold check_style() into the same job that validates source-layer and zoom ranges so one non-zero exit blocks the deploy.
Versioned deploy. Ship the validated style and the tiles it was checked against as a versioned pair — embed the schema hash in both the tile URL prefix and the style source URL so a stale cached tile can never be served against a filter written for a newer schema. The versioning mechanics live in the parent Map Styling & Layer Synchronization page. The key point for filters specifically is that the check in Steps 1–3 proves the style agrees with one particular tile build; that guarantee only holds in production if the client is served that same build. A hashed URL prefix makes the pairing physical: the style that passed the diff can only ever request the tiles it was diffed against, so the window in which a fresh style meets an old cached tile — the exact condition under which a filter silently blanks — is closed by construction rather than by cache-timing luck.
Troubleshooting
1. Filter matches nothing after a rename
Symptom: A layer that worked yesterday renders empty; tiles return HTTP 200 with correct MIME type.
Diagnosis:
# List the layer names actually present in the tile
pmtiles show roads.pmtiles --metadata | python3 -c \
"import sys,json; m=json.load(sys.stdin); print([l['id'] for l in m['vector_layers']])"
Fix: Compare the printed IDs to every source-layer in the style. If the layer was renamed (for example the input basename changed), remap the source-layer or pin the name with tippecanoe --layer. The full rename workflow is covered in syncing MapLibre filters with renamed source layers.
2. Type mismatch: string vs number in ==
Symptom: ["==", ["get", "class_id"], "3"] matches nothing even though class_id is present.
Diagnosis: Decode a tile and inspect the value type:
tippecanoe-decode roads.mbtiles 12 2094 1361 | \
jq '.features[0].properties.class_id'
If it prints 3 (a JSON number) but your filter compares against "3" (a string), MapLibre’s strict == never matches — it does not coerce.
Fix: Compare against the same type the tile stores: ["==", ["get", "class_id"], 3]. Enforce the tile-side type with tippecanoe --attribute-type=class_id:int so the contract is stable.
3. Source-layer typo
Symptom: A single layer is blank while its siblings render.
Diagnosis: Run the Step 3 diff — a typo like transportaton surfaces as source-layer '…' not in tile schema with the valid list printed alongside.
Fix: Correct the source-layer string to the exact schema ID. Add the diff to CI so the typo is caught before deploy rather than by eye.
4. Case sensitivity
Symptom: ["==", ["get", "class"], "Primary"] matches nothing; the data clearly contains primary roads.
Diagnosis: MapLibre string comparison is case-sensitive. Check the stored casing:
tippecanoe-decode roads.mbtiles 12 2094 1361 | \
jq '[.features[].properties.class] | unique'
Fix: Match the exact stored case ("primary"), or normalize casing at ingest so both the tile and the filter agree — lowercasing categorical strings during preprocessing removes this whole class of drift.
Further Reading
Syncing MapLibre Filters with Renamed Source Layers — the focused playbook for the most common filter break: a Tippecanoe layer whose name changed because the input basename or --layer value moved. Covers detecting the rename from tile metadata, remapping every source-layer and filter reference, and pinning the name so it cannot drift again.
Parent: Map Styling & Layer Synchronization
Related
- Dynamic Attribute Mapping — how the same
["get", KEY]references that filters read also drive data-driven paint and layout expressions, and how to generate both from the schema. - Style Validation Workflows — the broader CI gate that folds this filter diff in alongside JSON-schema linting and visual regression testing.
- MapLibre GL JSON Structure — the anatomy of
sources,layers,source-layer, and the expression grammar that filters are written in.