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:

bash
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.

One invocation against separate buildsTwo panels comparing a single Tippecanoe run over several inputs with independent builds merged by tile-join.TWO SHAPESOne invocationLayer names and zoom ranges per inputOne simplification setting for allOne dropping strategy for allAny change rebuilds everythingSimplest, and inflexibleSeparate builds, mergedEvery flag per layerBuilds run in parallelOne layer rebuilds independentlyMerge step re-checks the size budgetOne more step, full control
The choice is really about whether the layers want different treatment. If they genuinely do not, one invocation is simpler and there is no reason to compose.

Production Command

A Makefile expresses the shape well, because the dependency structure is the point:

makefile
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.

What a one-layer change actually re-runsFour independent layer builds feeding a merge, a size gate and a conversion, with only the changed layer and the downstream steps re-running.DEPENDENCY GRAPH3 layersunchangedcache hitbuildingsrebuiltthe only expensivesteptile-joinmerge all fourSize gatemerged tilesconvert.pmtiles
Three of the four expensive steps are skipped. That is the difference between a four-hour rebuild and a twenty-minute one.

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.

bash
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; }
Why four layers want four different flag setsFour layers with the zoom range, size-control strategy and simplification each needs, showing that no single invocation can express all four.PER-LAYER FLAGSzoom rangesize controlsimplificationroadsz5-z14drop densest8buildingsz13-z16coalesce densestdefaultlandcoverz4-z12none neededno tiny-polygonreductionwaterz0-z14none needed4
Every row differs from every other in at least two columns. That is the whole case for building them separately.

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.