Building Multi-Layer Tilesets from Separate Sources
Build each layer with the flags it actually needs, then merge. The alternative — one Tippecanoe invocation over several inputs — forces every layer to share one simplification setting, one dropping strategy and one zoom range, which is wrong for at least one of them in every real tileset.
When to Use This
Whenever two layers want different treatment. Roads need simplification and density-based dropping; building footprints need coalescing and a narrow zoom range; landcover needs neither and would be damaged by tiny-polygon reduction. A single invocation can vary very little of that per layer.
It is also the right shape when layers have different owners or update cadences, since each becomes an independently rebuildable artefact.
Specification Detail
| Approach | Per-layer flags | Rebuild one layer | Complexity |
|---|---|---|---|
| One invocation, several inputs | Zoom range only, via -L |
Rebuild everything | Lowest |
Separate builds plus tile-join |
All flags | Rebuild and re-merge that layer | Moderate |
Tippecanoe’s -L syntax does allow per-input layer naming and zoom ranges in a single run:
tippecanoe -o basemap.mbtiles -Z4 -z16 --force \
-L'{"file":"roads.geojson","layer":"roads","minzoom":5,"maxzoom":14}' \
-L'{"file":"buildings.geojson","layer":"buildings","minzoom":13,"maxzoom":16}'
That covers naming and zoom range and nothing else. --simplification, --drop-densest-as-needed and --coalesce-densest-as-needed remain global, which is precisely the limitation composition removes.
Production Command
A Makefile expresses the shape well, because the dependency structure is the point:
LAYERS := roads buildings landcover water
ARCHIVES := $(addprefix build/,$(addsuffix .mbtiles,$(LAYERS)))
build/roads.mbtiles: src/roads.geojson
tippecanoe -o $@ -l roads -Z5 -z14 --force \
--simplification=8 --drop-densest-as-needed \
--include name --include highway --include oneway $<
build/buildings.mbtiles: src/buildings.geojson
tippecanoe -o $@ -l buildings -Z13 -z16 --force \
--coalesce-densest-as-needed --include height $<
build/landcover.mbtiles: src/landcover.geojson
tippecanoe -o $@ -l landcover -Z4 -z12 --force \
--no-tiny-polygon-reduction --include class $<
build/water.mbtiles: src/water.geojson
tippecanoe -o $@ -l water -Z0 -z14 --force \
--simplification=4 --include name $<
dist/basemap.mbtiles: $(ARCHIVES)
tile-join -o $@ -f \
--name "Basemap v43" \
--attribution "© OpenStreetMap contributors" \
$(ARCHIVES)
@sqlite3 $@ "SELECT COUNT(*) FROM tiles WHERE length(tile_data) > 500000" \
| grep -qx 0 || { echo "merged tiles exceed 500 KB"; exit 1; }
dist/basemap.pmtiles: dist/basemap.mbtiles
pmtiles convert $< $@ && pmtiles verify $@
Changing src/buildings.geojson rebuilds one archive and re-runs the merge. The other three builds are skipped, which is the whole benefit.
Naming Discipline
Layer names are a published interface — a style’s source-layer must match one exactly — and composition makes it easy to change one by accident, because each build owns its own name.
Three rules keep this stable. Always pass -l explicitly, so the name never comes from a filename. Keep the names in one place, ideally the same manifest the style validation reads, rather than repeated across build commands. And assert after the merge that the set of layer names is exactly what the manifest says — a one-line check that catches both a typo and an accidental collision.
sqlite3 dist/basemap.mbtiles "SELECT value FROM metadata WHERE name='json';" \
| jq -r '.vector_layers[].id' | sort > /tmp/actual
sort layers.manifest > /tmp/expected
diff /tmp/expected /tmp/actual || { echo "layer set changed"; exit 1; }
Interaction Effects
With the size budget. Only the merged tile matters to a reader, and only the merge measures it. The gate belongs after tile-join, and a failure there should be fixed in whichever input contributes most — not by raising the limit.
With zoom ranges. The merged range is the union. A layer with -Z0 forces world-level tiles to exist across the whole tileset, in which every other layer contributes an empty layer. Give each layer the narrowest range it actually needs.
With incremental builds. Composition by layer and partitioning by region compose with each other: a layer can itself be built as several regional shards merged before the layer merge.
Performance Impact
The layer builds parallelise cleanly since they share nothing. The merge is single-writer and serial, and it grows with total tile count. On an 8-core machine with four layers totalling 12 million merged tiles:
| Step | Time | Parallel |
|---|---|---|
| Four layer builds | 34 min (longest 34) | Yes |
tile-join merge |
22 min | No |
| Size gate | 40 s | — |
| PMTiles conversion | 14 min | No |
The serial tail is 36 minutes and does not shrink with more cores, which is the practical ceiling on how fast a composed build can get.
Common Mistakes
Letting a layer name come from a filename. Renaming an input renames the published layer and breaks every style rule that referenced it.
Running the size gate on the inputs. Each input passes, the merged tile does not, and nothing catches it.
Merging with a shell glob. Expansion order can vary, which changes the layer order inside tiles, which changes the bytes without changing the content and defeats deduplication. Pass the inputs in a fixed order.
Giving every layer the tileset’s full zoom range. Produces a much larger archive than necessary, mostly consisting of layers at zooms where they are not drawn.
FAQ
Should every layer be its own tileset instead?
Only if it has its own cadence or owner. Separate tilesets mean separate sources in the style, and each source costs its own metadata fetch and its own tile requests per viewport.
Does merge order affect rendering?
No. MapLibre draws in the order the style declares, not the order layers appear inside a tile. Fix the order only for build reproducibility.
Can layers overlap in zoom range?
Yes, and they usually do. The union of ranges is what the tileset covers, and a given tile carries whichever layers exist at that zoom.
How many layers is too many?
There is no hard limit, but each layer carries its own key and value dictionaries per tile, so a dozen thin layers cost noticeably more than one layer with a class attribute. If layers differ only by a category value, they are probably one layer.
Related
- Layer Composition and tile-join — the parent topic, including the attribute-join capability.
- Tippecanoe CLI Fundamentals — the per-layer flags each build sets.
- Filtering Layers Out of an Existing Tileset — the inverse operation.
- CI/CD Tile Build Automation — running this dependency graph on every commit.