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.
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-joinwill 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.
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:
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
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
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:
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.
Step 5 — Convert and publish
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.
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.
Related
- Merging MBTiles Files with tile-join — the mechanics of the merge itself and its performance profile.
- Tippecanoe CLI Fundamentals — the per-layer flags each input build uses.
- Incremental and Partitioned Tile Builds — the same decomposition applied by region rather than by layer.
- Structuring MapLibre Styles for Multi-Source Tiles — why one composed tileset beats several sources.
- Automated Generation Pipelines with Tippecanoe — the parent section.