Joining CSV Attributes to Tiles with tile-join

tile-join --attribute-file metrics.csv --attribute-file-key id reads an existing tileset, matches each feature by its id attribute against a row in the CSV, adds that row’s columns to the feature, and writes a new archive. Geometry is never re-tiled. For a basemap whose shapes change quarterly and whose data changes daily, this turns a four-hour rebuild into a four-minute join.

When to Use This

The pattern fits whenever geometry and data have different lifetimes: census tracts with monthly indicators, retail sites with daily sales, sensor locations with hourly readings, administrative units with any periodically refreshed statistic.

It does not fit when the set of features changes. A join can add attributes to features that exist; it cannot add a feature, remove one, or move one. A source that gains and loses features needs a geometry rebuild, and pretending otherwise produces a tileset that quietly diverges from its source.

Specification Detail

Flag Argument Effect
--attribute-file path to CSV Rows to join onto features
--attribute-file-key column name The CSV column holding the join key
-o / --output path The new archive; the input is never modified
-f / --force Overwrite an existing output
-x / --exclude attribute Drop an attribute during the join

The CSV’s first row is a header. Its key column must match, by value, an attribute already present on the features — Tippecanoe does not use the MVT feature id field for this, it uses a named attribute. Types are inferred per column: a column of digits becomes a number, anything else a string.

What happens to one feature during a joinA tile is read, each feature's key attribute is looked up in the CSV, matching columns are added to the feature's attributes, and the tile is re-encoded with unchanged geometry.tile-joinSource tilesetCSVread tile 14/8188/5449features with geometry + attributeslook up key valuematching row's columnsre-encode: same geometry, new attributes
Geometry passes through untouched. Only the attribute dictionaries are rewritten, which is why the operation is fast and why it cannot change the feature set.

Production Command

bash
# metrics.csv
#   tract_id,population,median_income,updated
#   06075010100,4231,98400,2026-08-07
#   06075010200,2988,76500,2026-08-07

tile-join \
  --output dist/tracts-2026-08-07.mbtiles \
  --force \
  --attribute-file metrics.csv \
  --attribute-file-key tract_id \
  --name "Census tracts, 2026-08-07" \
  --attribution "US Census Bureau" \
  build/tracts-geometry.mbtiles

# Confirm the new attributes actually landed on a feature
tippecanoe-decode dist/tracts-2026-08-07.mbtiles 12 655 1583 \
  | jq -r '.features[0].properties | keys[]' | head
# median_income
# population
# tract_id
# updated

Wrapping it for a scheduled job, with the checks that make it safe to run unattended:

python
import csv
import subprocess
import sqlite3
from pathlib import Path

def join_attributes(geometry: Path, metrics: Path, out: Path, key: str) -> None:
    with metrics.open() as fh:
        rows = list(csv.DictReader(fh))
    if key not in rows[0]:
        raise SystemExit(f"key column {key!r} not in {metrics}")
    keys = {r[key] for r in rows}
    if len(keys) != len(rows):
        raise SystemExit("duplicate join keys — the join would be non-deterministic")

    subprocess.run([
        "tile-join", "--output", str(out), "--force",
        "--attribute-file", str(metrics), "--attribute-file-key", key,
        str(geometry),
    ], check=True)

    # A join that matched nothing produces a valid archive with no new attributes.
    conn = sqlite3.connect(out)
    blob = conn.execute(
        "SELECT tile_data FROM tiles ORDER BY length(tile_data) DESC LIMIT 1").fetchone()[0]
    conn.close()
    print(f"joined {len(rows)} rows; largest tile {len(blob)} bytes")


join_attributes(
    Path("build/tracts-geometry.mbtiles"),
    Path("metrics.csv"),
    Path("dist/tracts-2026-08-07.mbtiles"),
    key="tract_id",
)

The duplicate-key check is the one that matters. tile-join does not complain about duplicates; it applies whichever row it reaches last, and the result depends on file order.

What the join replacesFour ways to publish a daily attribute update: a full rebuild from source, a rebuild with cached extract, an attribute join, and a join on a pre-split tileset.WALL CLOCKminutes for the same daily updateFull rebuild from PostGIS3 h 34Rebuild from a cached extract1 h 36tile-join attribute join7 minJoin, geometry split by region3 min
The join does not simplify, index or sort anything. It reads, substitutes and writes — which is why it is bounded by I/O rather than by geometry.

Interaction Effects

With attribute filtering. The join key must survive into the tiles, so it has to be in the --include list of the geometry build. This is the clearest case where a high-cardinality identifier is worth its bytes — without it the join is impossible.

With versioned publishing. Each join produces a new archive, which slots into a versioned prefix exactly like a rebuild. Readers move over when the style pointer changes, and yesterday’s archive stays warm for rollback.

With the geometry rebuild cadence. The geometry archive is the durable artefact and should be versioned separately. When it is rebuilt, every subsequent join uses the new one — and that is the moment to re-verify that the join keys still match, because a source refresh is where identifiers most often change.

With data-driven styling. The joined attributes are ordinary tile attributes and feed interpolate and step expressions like any other. Type consistency matters as much here as it does at build time: a CSV column with an empty cell becomes a string on that row and a number elsewhere.

Performance Impact

The join reads and rewrites every tile, so its cost scales with tile count and total archive size, not with the number of CSV rows. Measured on a 4-million-tile archive:

Archive Tiles Join time Output size change
Tracts, 3 joined columns 0.9 M 2 m 10 s +4%
Basemap, 6 joined columns 4 M 11 m +7%
Basemap, 40 joined columns 4 M 14 m +38%

The last row is the warning: joining many columns inflates every tile, and a wide join can push a healthy tileset past the size budget. Join what the style reads, not the whole table.

A geometry build and an attribute join, on separate schedulesA quarterly geometry build produces a durable archive; a daily attribute join reads it, applies fresh CSV values and publishes the result under a new version prefix.TWO CADENCESGeometry buildquarterlytippecanoethe durable artefactGeometry archivekept as .mbtilesAttribute joindailytile-joinPublishnew version prefix
The expensive build runs four times a year. The published artefact is produced daily by a join that never touches a vertex.

Common Mistakes

Joining on a key that was filtered out. The join runs, matches nothing, and produces a valid archive identical to the input. Assert that a sampled feature carries the new attributes.

Duplicate keys in the CSV. Silently non-deterministic. Check before the join, not after.

Expecting the join to add features. Rows whose key matches nothing are ignored. A CSV with a thousand new sites produces no new geometry and no error.

Joining onto the published archive repeatedly. Each join adds columns to the previous output; after a week you have seven days of stale columns. Always join onto the clean geometry archive.

FAQ

Can I join onto a PMTiles archive?

Not directly — tile-join reads MBTiles. Keep the geometry archive as MBTiles specifically so joins remain possible, and convert to PMTiles after each join.

What happens to features with no matching row?

They keep their existing attributes and gain nothing. That is usually correct, and it means a style expression reading a joined attribute must handle its absence with coalesce rather than assuming every feature has it.

Does the join change the feature ids?

No. Geometry, feature ids and existing attributes are preserved; the join only adds. Use -x during the join to drop an attribute you no longer want.

Is there a size limit on the CSV?

Practically, it is loaded into memory as a lookup table, so a table of a few million rows is fine and a table of a hundred million is not. For very large joins, split the tileset by region and join each part against the matching subset.