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.
Production Command
# 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:
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.
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.
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.
Related
- Layer Composition and tile-join — the parent topic and the merge side of the same tool.
- Attribute Filtering Rules — keeping the join key while dropping everything else.
- Rebuilding Only Changed Regions in a Tile Pipeline — the geometry-side equivalent of this optimisation.
- Data-Driven Color Ramps with Interpolate Expressions — what the joined values are usually for.