Layer Composition and tile-join

A tileset with six layers does not have to come from one Tippecanoe run. Building each layer separately and composing them afterwards decouples their update cadences, lets each carry its own flags, and turns a whole-tileset rebuild into a merge. tile-join is the tool that does the composing, and it also joins attributes onto tiles that already exist — a genuinely different capability that saves entire rebuilds.

Independent builds, one composed tilesetThree layers built separately with their own flags and cadences are merged by tile-join into a single archive, which is then verified and published.COMPOSITIONroadsz5-z14--drop-densestbuildingsz13-z16--coalescelandcoverz4-z12tile-joinunion of tilesconcat layersre-checks the sizebudget per tilebasemapone archive
Each source keeps its own flags and schedule. Only the merge step has to know that they end up together.

Prerequisites

Requirement Why
Tippecanoe 2.x with tile-join Ships alongside tippecanoe in the same package
Each input as .mbtiles tile-join reads MBTiles; convert to PMTiles last
Consistent compression across inputs Mixing gzipped and raw tiles produces an unreadable output
Distinct layer names Same name in two inputs merges the features into one layer

Core Concept: What a Merge Actually Does

tile-join walks the union of tile addresses across its inputs. For each z/x/y, it concatenates the layers found in each input into a single tile, then re-applies the size limit. Three consequences follow, and all three surprise people at some point:

  • The zoom range is the union. Merging a z4–z12 landcover build with a z13–z16 buildings build produces a z4–z16 tileset, in which the low zooms hold only landcover and the high zooms only buildings. That is usually what you want and it is worth being deliberate about.
  • Two tiles that each fit can overflow together. The budget applies to the merged tile. A 300 KB roads tile and a 280 KB buildings tile produce a 580 KB tile that tile-join will thin or reject.
  • Metadata is not merged intelligently. Names, attributions and bounds come from whichever input wins, which is why they should be passed explicitly on every merge.
What each precondition protects againstFour merge preconditions with the failure they prevent and how visible that failure is.PRECONDITIONSfailure if ignoredhow it showsDistinct layer namesFeatures merged intoone layerSilent — styleshalf-matchCompatible zoom rangesUnexpectedly wide rangeSilent — extra emptytilesMerged size under 500 KBFeatures thinned atmergeLoud — tile-join warnsSame tile compressionUnreadable archiveLoud — client fails
The quiet failures are in the top two rows. Both produce an archive that opens, verifies and renders wrongly.

Step-by-Step Implementation

Step 1 — Build each layer with its own flags

The point of composing is that each layer gets what it needs. Roads want simplification and dropping; buildings want coalescing; landcover wants neither and a shallower range:

bash
tippecanoe -o build/roads.mbtiles -l roads -Z5 -z14 \
  --drop-densest-as-needed --simplification=8 --force roads.geojson

tippecanoe -o build/buildings.mbtiles -l buildings -Z13 -z16 \
  --coalesce-densest-as-needed --force buildings.geojson

tippecanoe -o build/landcover.mbtiles -l landcover -Z4 -z12 \
  --no-tiny-polygon-reduction --force landcover.geojson

Step 2 — Check the preconditions before merging

bash
for f in build/*.mbtiles; do
  echo "== $f"
  sqlite3 "$f" "SELECT value FROM metadata WHERE name='json';" \
    | jq -r '.vector_layers[] | "\(.id)  z\(.minzoom)-z\(.maxzoom)"'
  sqlite3 "$f" "SELECT MAX(length(tile_data)) FROM tiles;"
done

Two things to read out of that: no layer id may appear in more than one file, and the sum of the largest tiles across inputs is the worst case the merge has to fit.

Step 3 — Merge

bash
tile-join \
  --output build/basemap.mbtiles \
  --force \
  --name "Basemap v43" \
  --attribution "© OpenStreetMap contributors" \
  --no-tile-size-limit=false \
  build/roads.mbtiles build/buildings.mbtiles build/landcover.mbtiles

Passing --name and --attribution explicitly is not optional in a pipeline. Inheriting them means the published tileset’s identity depends on argument order.

Step 4 — Re-verify the budget

The merge is where a tileset most often crosses the size limit, because nothing before it saw the combined tile:

bash
sqlite3 build/basemap.mbtiles \
  "SELECT zoom_level, tile_column, tile_row, length(tile_data) AS bytes
   FROM tiles WHERE length(tile_data) > 500000
   ORDER BY bytes DESC LIMIT 10;"

If tiles appear here, the fix belongs in the input build that contributes most to them — tighten that layer’s zoom range or dropping strategy — not in the merge.

How three individually healthy layers overflow togetherThree layers each measured well inside the 500 KB budget, and their merged tile exceeding it.BUDGET STACKINGKB in the worst z14 tileroads alone300 KBbuildings alone280 KBlandcover alone90 KBmerged tile670 KB500 KB budget
Every input passes its own check. The merge is the first moment anything measures the tile a reader actually receives.

Step 5 — Convert and publish

bash
pmtiles convert build/basemap.mbtiles dist/basemap.pmtiles
pmtiles verify dist/basemap.pmtiles

Conversion belongs at the end because tile-join reads MBTiles. The conversion guide covers what the step does to ordering and deduplication.

The Attribute Join, and Why It Is the Interesting Capability

Merging archives is the obvious use of tile-join and the less valuable one. The capability that changes how a pipeline is shaped is the attribute join: attaching new attribute values to features in an existing tileset, keyed on a shared identifier, without re-tiling any geometry.

The case it solves is common. A basemap’s geometry changes quarterly; the data painted onto it changes daily. Rebuilding the whole tileset every day to update one numeric column means re-simplifying millions of geometries to produce bytes that are, geometrically, identical to yesterday’s. An attribute join reads the existing tiles, replaces the attribute values, and writes a new archive — minutes instead of hours, and with no risk that a simplification setting drifted between builds.

bash
tile-join \
  --output build/basemap-2026-08-07.mbtiles \
  --force \
  --attribute-file daily_metrics.csv \
  --attribute-file-key feature_id \
  build/basemap-geometry.mbtiles

Two constraints make or break it. The join key must be present in the tiles, which means it has to survive attribute filtering — the one place a high-cardinality identifier unambiguously earns its bytes. And the key must be stable across geometry rebuilds, or a quarterly geometry refresh silently breaks every subsequent join.

The result is a pipeline with two cadences instead of one: a slow geometry build that produces the durable artefact, and a fast attribute join that produces the published one. Each has its own failure modes, each can be rolled back independently, and the expensive one runs four times a year instead of every night.

Composition Boundaries: What Belongs in One Tileset

Composing makes it cheap to put layers together, which makes it worth being deliberate about which layers should be. Three questions decide it.

Do they change on the same cadence? Layers that update together belong together, because they will be republished together anyway. A layer that updates hourly inside a tileset that is otherwise quarterly forces the whole archive to be republished hourly, and every reader to refetch it.

Are they always drawn together? A layer that is only visible in one application mode — an editor overlay, a debug layer, a print variant — costs every reader bytes for something most of them never see. Split it, and let the style add the source when the mode is entered.

Do they share an owner? A tileset is a published interface, and an interface with two owners has coordination costs on every change. Separate tilesets give each team its own rollback.

Against those, the cost of splitting is real and easy to underestimate: each additional tileset is another source in the style, another metadata fetch, and another set of tile requests per viewport. The multi-source structuring guide quantifies that cost, and the short version is that it is larger than most teams expect. Composing aggressively and splitting only when one of the three questions above says to is the arrangement that holds up.

Ordering Inside the Merged Tile

Layer order within a tile is the order tile-join encountered the inputs, and it is worth knowing that it does not matter — MapLibre draws in the order the style declares, not the order layers appear in the tile. A tileset whose internal order looks wrong is not a problem to fix.

What does matter is that the order is stable between builds, because an unstable order changes the tile bytes without changing the content, which defeats content-hash deduplication and makes every rebuild publish a genuinely new archive. Passing the inputs in a fixed, scripted order — not a shell glob whose expansion depends on the filesystem — is enough to guarantee it.

Optimization Knobs

Knob Conservative Aggressive Trade-off
Layers per tileset Split by cadence One tileset for everything Fewer tilesets means fewer sources and round trips; more tilesets means independent rebuilds
--no-tile-size-limit Leave off Enable to force a merge through Turning it on ships tiles clients may silently drop
Attribute join versus rebuild Rebuild Join with tile-join A join updates attributes without re-tiling geometry, in minutes instead of hours
Merge frequency On every layer change Nightly Frequent merges are cheap; frequent rebuilds are not

Integration With Adjacent Pipeline Stages

Upstream, each layer is an ordinary Tippecanoe build with its own flag set. Composition is what lets those flag sets differ.

Downstream, the merged tileset is one source with several source-layer values, which is the arrangement a style prefers — it costs one round trip rather than several.

Sideways, composition pairs naturally with incremental builds: if each layer is a separate artefact, rebuilding one and re-merging is far cheaper than rebuilding all of them.

Troubleshooting

Layers silently merged into one

Symptom: A style layer that filtered on class now matches features it should not.

Cause: Two inputs used the same layer name, so tile-join concatenated their features into one layer.

Fix: Rename at build time with -l, and assert distinct names in the precondition check above.

The merged archive lost its vector_layers

Symptom: Every style layer renders empty after a merge that previously worked.

Cause: Neither input carried the metadata forward — see fixing missing vector_layers.

Fix: Rebuild the row from a tile scan, and assert it exists after every merge.

Merge output far larger than the sum of the inputs

Symptom: Three 2 GB archives merge into a 9 GB one.

Cause: The union of zoom ranges. A layer with a deep range forces tiles to exist at those zooms across the whole extent, and the other layers contribute empty layers in each.

Fix: Narrow each input’s range to what that layer needs, or accept the cost knowing what caused it.

Composition and the Style Contract

One consequence of composing is worth stating plainly: the merged tileset is the published interface, and its layer set is now assembled from several independent builds. Nothing in any single build can see whether that assembled set is correct.

That makes the post-merge assertion — the layer names in the output equal the names in the manifest — the only place the contract is actually checked. It belongs after tile-join and before publish, and it should compare against a committed manifest rather than against the previous build, so that an accidental removal is caught rather than inherited.

In-Depth Guides

Joining CSV Attributes to Tiles with tile-join — updating attributes on an existing tileset without re-tiling geometry.

Building Multi-Layer Tilesets from Separate Sources — the full composition pattern, including per-layer zoom ranges and naming discipline.

Filtering Layers Out of an Existing Tileset — producing a narrower tileset from a wider one without going back to the source data.

FAQ

Does tile-join re-encode tiles?

It decodes and re-encodes each tile it touches, because concatenating layers means writing a new protobuf message. It does not re-simplify or re-project anything — geometry passes through unchanged.

Can I merge PMTiles archives?

Not directly. Convert to MBTiles, merge, and convert back, or keep the MBTiles intermediates around specifically so merges stay cheap.

What happens if the inputs have different tile compression?

The output is inconsistent and no client reads it reliably. Normalise before merging; every Tippecanoe build produces gzip by default, so this only arises with hand-assembled inputs.

Is composing slower than one big build?

Usually faster overall, because each layer builds in parallel and only the merge is serial. It is slower for a single layer changed rarely, where one build would have sufficed.

Next reading Building Multi-Layer Tilesets from Separate Sources Next reading Filtering Layers Out of an Existing Tileset Next reading Joining CSV Attributes to Tiles with tile-join