Attribute Filtering Rules for Vector Tile Pipelines

Raw geospatial datasets routinely carry hundreds of columns — administrative codes, audit timestamps, internal identifiers, and legacy metadata — that are irrelevant to a browser renderer. When those attributes pass unfiltered into Tippecanoe, they inflate Protobuf payloads, degrade client-side parsing performance, and complicate style maintenance. Implementing deterministic, version-controlled attribute filtering ensures only rendering-relevant properties survive the transformation from source data to cached Mapbox Vector Tiles stored in an MBTiles SQLite container.

Prerequisites

Before implementing filtering logic, confirm this environment baseline:

Requirement Minimum version / state
Tippecanoe 2.0+ (tippecanoe --version)
Python 3.9+
pyarrow 12.0+
Source format GeoParquet, GeoJSON, or PostGIS export in EPSG:4326
Style file MapLibre GL or Mapbox GL JSON available for attribute audit
CI runner Docker-isolated with locked dependency manifest

For teams standardizing on columnar storage, GeoParquet input processing covers schema alignment and partition strategies that must be resolved before attribute selection begins.

Core Concept: The Tippecanoe Attribute Flag Set

Tippecanoe controls attribute inclusion and exclusion through four flags. Apply them to the same invocation; -y and -x can each be repeated.

Flag Long form Behavior Notes
-y ATTR --include=ATTR Keep only named attribute Use once per attribute; mutually exclusive with -x on the same key
-x ATTR --exclude=ATTR Drop named attribute Useful when keeping most columns but removing a handful
--exclude-all Drop every attribute Geometry-only output; combine with -y to re-add specific keys
-T ATTR:TYPE --attribute-type=ATTR:TYPE Force attribute to int, float, string, or bool Applies after -y filtering; overrides source type

When -y flags are active, Tippecanoe silently drops any attribute not on the allowlist. This prevents accidental metadata leakage into production tiles and is the preferred mode for production builds. The -x approach is better suited to exploratory pipelines where the full column list is unknown at invocation time.

How Attribute Bloat Propagates Through the MVT Format

The Mapbox Vector Tile specification stores attributes in a per-layer dictionary: keys are written once into a keys array, values are deduplicated into a values array, and each feature references them by integer index. An unused column still occupies entries in both arrays across every tile that contains it — even when the value is null.

The diagram below shows where filtering intercepts the pipeline and what remains unfiltered without it.

Attribute filtering pipeline stages Diagram showing the three stages of an attribute filtering pipeline: source dataset with all columns, pre-filter step that applies a JSON policy to drop unwanted columns, and Tippecanoe encoding that applies the -y allowlist flag to produce lean MVT tiles. Source Dataset height floors name legacy_id audit_ts contractor_ref JSON Policy JSON Policy keep: [height, floors, name] drop: [legacy_id, audit_ts, contractor_ref] coerce: {floors: int} Tippecanoe -y height -y floors -y name → buildings.mbtiles 3 keys in MVT dictionary pyarrow -y flag Output lean .pbf 3 keys only

Step-by-Step Implementation

Step 1 — Audit Source Attributes Against Style Usage

Run a schema inventory before writing a single filter rule. Parse your MapLibre GL or Mapbox GL style JSON, extract every attribute referenced in paint, layout, and filter expressions, then diff that set against the source dataset schema.

python
import json, pathlib

def extract_style_attributes(style_path: str) -> set[str]:
    style = json.loads(pathlib.Path(style_path).read_text())
    attrs: set[str] = set()

    def walk(obj):
        if isinstance(obj, list):
            # Mapbox expression: ["get", "attr_name"] or ["==", ["get", "attr"], value]
            if len(obj) >= 2 and obj[0] == "get" and isinstance(obj[1], str):
                attrs.add(obj[1])
            for item in obj:
                walk(item)
        elif isinstance(obj, dict):
            for v in obj.values():
                walk(v)

    for layer in style.get("layers", []):
        walk(layer.get("paint", {}))
        walk(layer.get("layout", {}))
        walk(layer.get("filter", []))

    return attrs

used = extract_style_attributes("style.json")
print("Attributes used by style:", sorted(used))

Any column not in used — plus any column powering hover tooltips or click popups — is a candidate for removal. Columns that feed server-side analytics but not the frontend should be dropped at this stage. For a deep dive into systematic bloat elimination, see Dropping Unused Attributes to Reduce Tile Size.

Verify: Column count before vs after the audit. A typical OpenStreetMap buildings export has 30–60 columns; a styled buildings layer usually needs 4–8.

Step 2 — Define a Deterministic JSON Filtering Policy

Codify the audit output as a versioned JSON policy. Store it in version control alongside your tile configuration so every pipeline run is reproducible.

json
{
  "policy_version": "1.2",
  "layers": {
    "buildings": {
      "keep": ["height", "floors", "building_type", "name"],
      "drop": ["legacy_id", "internal_audit_flag", "created_at", "updated_by"],
      "coerce": {
        "height": "float",
        "floors": "int",
        "is_heritage": "bool"
      },
      "rename": {
        "bldg_type": "building_type"
      }
    },
    "roads": {
      "keep": ["maxspeed", "surface", "oneway", "name", "highway"],
      "drop": ["maintenance_schedule", "contractor_id", "audit_ts"],
      "coerce": { "maxspeed": "int" },
      "rename": {}
    }
  }
}
Policy key Purpose
keep Allowlist — only these attributes survive pre-filtering
drop Explicit blocklist — documents why each column is removed
coerce Convert unsupported types (timestamps → string, enum → bool)
rename Normalize keys across datasets for consistent style targeting

Verify: Policy file committed to git and peer-reviewed. Any change to keep or drop must trigger a tile rebuild.

Step 3 — Execute Pre-Filtering with pyarrow

Apply the policy before invoking the tiler. Pre-filtering reduces memory pressure during geometry simplification and ensures type coercion does not conflict with Tippecanoe’s internal type inference.

python
import pyarrow.parquet as pq
import pyarrow as pa
import pyarrow.compute as pc
import json, pathlib

def apply_filter_policy(
    input_path: str,
    output_path: str,
    policy_path: str,
    layer: str
) -> dict:
    policy = json.loads(pathlib.Path(policy_path).read_text())
    layer_policy = policy["layers"][layer]

    table = pq.read_table(input_path)
    schema_names = set(table.schema.names)

    # 1. Rename columns first so later selects use canonical names
    for old, new in layer_policy.get("rename", {}).items():
        if old in schema_names:
            idx = table.schema.get_field_index(old)
            table = table.rename_columns(
                [new if i == idx else table.schema.names[i]
                 for i in range(len(table.schema.names))]
            )

    # 2. Build keep list (geometry is mandatory)
    keep_cols = [c for c in layer_policy["keep"] if c in table.schema.names]
    geo_cols  = [c for c in ["geometry"] if c in table.schema.names]
    table = table.select(keep_cols + geo_cols)

    # 3. Coerce types to MVT-compatible values
    type_map = {"int": pa.int64(), "float": pa.float64(), "bool": pa.bool_()}
    for col_name, target in layer_policy.get("coerce", {}).items():
        if col_name in table.schema.names and target in type_map:
            idx = table.schema.get_field_index(col_name)
            table = table.set_column(
                idx, col_name,
                table[col_name].cast(type_map[target], safe=False)
            )

    pq.write_table(table, output_path, compression="snappy")
    return {"input_cols": len(schema_names), "output_cols": len(table.schema.names)}

stats = apply_filter_policy(
    "buildings_raw.geoparquet",
    "buildings_filtered.geoparquet",
    "filter_policy.json",
    "buildings"
)
print(f"Columns reduced: {stats['input_cols']}{stats['output_cols']}")

Verify: pq.read_schema("buildings_filtered.geoparquet").names should match keep + ["geometry"] exactly.

Step 4 — Apply Tippecanoe -y Flags as a Final Gate

Use Tippecanoe’s -y / --include flag as a second enforcement layer. Even if pre-filtering succeeds, this prevents any accidental column reintroduction (e.g., from a schema migration or a new source file).

bash
tippecanoe \
  --output=buildings.mbtiles \
  --layer=buildings \
  -y height -y floors -y building_type -y name \
  --drop-densest-as-needed \
  --maximum-zoom=16 \
  --minimum-zoom=10 \
  --coalesce-densest-as-needed \
  --no-tile-size-limit \
  buildings_filtered.geoparquet

For a full reference on production flag combinations including --drop-densest-as-needed, rate-control flags, and zoom-range tuning, consult Tippecanoe CLI Fundamentals.

Verify: tippecanoe-decode buildings.mbtiles 14/8239/5467 | python3 -c "import sys,json; d=json.load(sys.stdin); print(list(d['features'][0]['properties'].keys()))" — output should list only the four allowed keys.

Step 5 — Validate in CI/CD Before Promoting to Staging

Attribute filtering rules must be validated automatically before tiles reach staging or production.

bash
#!/usr/bin/env bash
# ci/validate_tiles.sh
set -euo pipefail

TILE_PATH="$1"          # e.g., buildings.mbtiles
POLICY_PATH="$2"        # e.g., filter_policy.json
LAYER="$3"              # e.g., buildings
MAX_TILE_KB="${4:-500}"  # default 500 KB budget per tile

ALLOWED=$(python3 -c "
import json, sys
p = json.load(open('$POLICY_PATH'))
print(' '.join(p['layers']['$LAYER']['keep']))
")

# 1. Schema diff: sample tile at z14 center
SAMPLE=$(tippecanoe-decode "$TILE_PATH" 14/8239/5467 2>/dev/null \
  | python3 -c "import sys,json; f=json.load(sys.stdin)['features']; print(json.dumps(list(f[0]['properties'].keys()) if f else []))")

# 2. Check for forbidden keys
for key in $(sqlite3 "$TILE_PATH" "SELECT DISTINCT json_each.value FROM tiles, json_each(tilejson) LIMIT 1" 2>/dev/null || echo ""); do
  if ! echo "$ALLOWED" | grep -qw "$key"; then
    echo "FAIL: forbidden attribute '$key' found in tile" && exit 1
  fi
done

# 3. Tile size regression — 95th percentile
P95=$(sqlite3 "$TILE_PATH" \
  "SELECT length(tile_data) FROM tiles ORDER BY length(tile_data) DESC LIMIT 1" \
  | awk '{printf "%.0f", $1/1024}')

if [ "$P95" -gt "$MAX_TILE_KB" ]; then
  echo "FAIL: max tile size ${P95}KB exceeds budget ${MAX_TILE_KB}KB" && exit 1
fi

echo "PASS: schema and size checks passed (max tile: ${P95}KB)"

Integrate this script into your GitHub Actions or GitLab CI pipeline. Gate merges to main on a successful exit code. Store filter_policy.json in the same repository as your tile generation config and require peer review for any keep/drop modifications.

Optimization Knobs

Parameter Conservative Aggressive Trade-off
keep list size 8–12 attributes 3–5 attributes More attrs = richer interactivity; fewer = smaller tiles and faster parsing
Pre-filter stage pyarrow before tiling Skip, rely on -y only Pre-filtering reduces Tippecanoe memory by 20–40% on wide schemas
Type coercion Explicit cast for every numeric Let Tippecanoe infer Explicit cast prevents silent string serialization of speed_limit values
Null handling Replace with sentinel (e.g., -1) Keep null MVT compresses null poorly; sentinels reduce payload but require style handling

Integration with Adjacent Pipeline Stages

Filtered GeoParquet output from Step 3 feeds directly into Tippecanoe (Step 4), which writes an MBTiles SQLite container. From there, the .mbtiles file is either served directly via a tile server like Martin, or converted to .pmtiles for cloud-native CDN delivery.

When binding filtered attributes to MapLibre GL style expressions for data-driven styling, the keep list in your policy becomes the contract between the pipeline and the frontend team. Any attribute a style expression references via ["get", "attr"] must appear in keep, or the expression silently returns null at runtime.

If your pipeline feeds a MapLibre GL JSON structure that targets multiple source layers, maintain one policy block per layer. Mismatched keys between layers cause style expression failures that only surface at render time, not at tile validation time.

Troubleshooting

Filtered tiles still contain unexpected attributes

Diagnosis: Pre-filter and Tippecanoe -y flags out of sync.

bash
tippecanoe-decode buildings.mbtiles 14/8239/5467 \
  | python3 -c "import sys,json; d=json.load(sys.stdin); \
    props=d['features'][0]['properties']; print(sorted(props.keys()))"

Fix: Confirm filter_policy.json keep list matches -y flags exactly. Regenerate the filtered GeoParquet, then re-run Tippecanoe.

null values persist after coercion, causing type errors in style filters

Diagnosis: pa.bool_() cast on a column with null values raises ArrowInvalid in safe mode.

python
# Reproduce
table["is_heritage"].cast(pa.bool_(), safe=True)  # raises ArrowInvalid if nulls present

Fix: Fill nulls before casting: pc.if_else(pc.is_null(table["is_heritage"]), False, table["is_heritage"].cast(pa.bool_())).

Array-type columns cause Tippecanoe to emit WARNING: ... is not a string, number, or boolean

Diagnosis: MVT does not support arrays. Source column contains JSON arrays or list types.

bash
ogrinfo -al -so buildings_filtered.geoparquet | grep "Array"

Fix: Flatten array fields during the coerce phase: extract the primary element with pc.list_slice(col, 0, 1) or join with pc.binary_join_element_wise.

Tile size budget exceeded after adding attributes to keep

Diagnosis: Adding attributes increases tile payload beyond the 500 KB ceiling at peak zoom levels.

bash
sqlite3 buildings.mbtiles \
  "SELECT zoom_level, COUNT(*), MAX(length(tile_data))/1024 AS max_kb \
   FROM tiles GROUP BY zoom_level ORDER BY zoom_level"

Fix: Add --drop-densest-as-needed or reduce --maximum-zoom by one level. Alternatively, split the layer and apply a separate, shorter keep list to the high-density zoom range.

Renamed attributes not reaching the style layer

Diagnosis: rename in the policy was applied but the style still references the old key.

bash
# Check properties in a decoded tile
tippecanoe-decode buildings.mbtiles 14/8239/5467 \
  | python3 -c "import sys,json; d=json.load(sys.stdin); \
    [print(f['properties']) for f in d['features'][:3]]"

Fix: Update style ["get", "old_name"] references to the new canonical key, or add old_name as an alias in keep temporarily during the migration window.

Child Pages


Parent: Automated Generation Pipelines with Tippecanoe

Related:

Next reading Dropping Unused Attributes to Reduce Tile Size