← Back to Vector Tile Endpoints with ST_AsMVT
This page shows how to return roads, buildings, water and labels as separate named layers inside a single tile response, generated by one database round trip.
Context & When to Use
A map style refers to layers by name: road-major, building-fill, water. If each of those is its own tile endpoint, a viewport showing nine tiles at four layers issues 36 HTTP requests, opens 36 cache entries, and holds 36 connections open against the pool. The rendering cannot start until the slowest of them lands, so the extra parallelism buys nothing — it only multiplies the fixed costs.
Packing them into one tile removes all of that. The Mapbox Vector Tile format is a protobuf whose top level is a repeated layer field, and protobuf message concatenation merges repeated fields. That means layer_a_bytes || layer_b_bytes is a valid two-layer tile, with no re-encoding step. PostGIS can produce both halves in one statement, so the whole tile is one query, one connection and one cache key.
Use this for any base map or multi-theme overlay. Keep layers separate only when their update cadences differ enough that you want distinct cache lifetimes — live vehicle positions alongside static parcel boundaries, for instance, where mixing them would force the slow-changing layer to expire at the fast layer’s rate. The tile mechanics themselves are covered on the ST_AsMVT topic page.
Runnable Implementation
-- One tile, three named layers, one round trip
WITH bounds AS (
SELECT ST_TileEnvelope($1, $2, $3) AS merc,
ST_Transform(ST_TileEnvelope($1, $2, $3), 4326) AS wgs
),
roads AS (
SELECT r.id, r.class_code,
ST_AsMVTGeom(ST_SimplifyPreserveTopology(ST_Transform(r.geom, 3857),
tile_tolerance($1)), b.merc, 4096, 64, true) AS geom
FROM roads r CROSS JOIN bounds b
WHERE r.geom && b.wgs AND r.min_zoom <= $1
),
buildings AS (
SELECT bl.id, bl.height_m,
ST_AsMVTGeom(ST_Transform(bl.geom, 3857),
b.merc, 4096, 64, true) AS geom
FROM buildings bl CROSS JOIN bounds b
-- Buildings are meaningless below z13; skip the work entirely
WHERE $1 >= 13 AND bl.geom && b.wgs
),
labels AS (
SELECT l.id, l.name, l.rank,
ST_AsMVTGeom(ST_Transform(l.geom, 3857),
b.merc, 4096, 8, false) AS geom -- points: no clipping
FROM place_labels l CROSS JOIN bounds b
WHERE l.geom && b.wgs AND l.rank <= GREATEST($1 - 4, 1)
)
SELECT
COALESCE((SELECT ST_AsMVT(roads.*, 'road', 4096, 'geom')
FROM roads WHERE geom IS NOT NULL), ''::bytea) ||
COALESCE((SELECT ST_AsMVT(buildings.*, 'building', 4096, 'geom')
FROM buildings WHERE geom IS NOT NULL), ''::bytea) ||
COALESCE((SELECT ST_AsMVT(labels.*, 'label', 4096, 'geom')
FROM labels WHERE geom IS NOT NULL), ''::bytea) AS mvt;Three details carry the design. Each layer has its own WHERE clause, so zoom rules are per layer rather than global. COALESCE(..., ''::bytea) makes an empty layer contribute nothing instead of turning the whole expression NULL. And the label layer passes clip_geom = false with a small buffer, because clipping a point is pointless and a label just outside the tile still needs to exist for collision detection.
Key Parameters & Options
| Choice | Recommended | Why |
|---|---|---|
Layer name in ST_AsMVT | matches the style’s source-layer | The renderer looks it up by string; a typo silently renders nothing |
COALESCE(…, ''::bytea) | always | One empty layer would otherwise null the entire concatenation |
| Per-layer zoom gate | in the CTE WHERE | Skips the scan, not just the encode |
clip_geom | true for lines and polygons, false for points | Clipping a point can only remove it |
| Buffer | 64 for lines/polygons, 8 for points | Points need only enough room for label collision |
| Layer order | cheap layers first | The statement short-circuits nothing, but the plan reads better and profiles cleanly |
Budgeting the combined size
The single risk of a combined tile is that the total quietly grows past what a mobile client can decode smoothly. Track it per layer, and thin the layer contributing most before reaching for global simplification.
Gotchas & Failure Modes
- A
NULLlayer nulls the tile. WithoutCOALESCE, a zoom level where one layer is empty returnsNULLfor the whole concatenation and the route sends a blank tile — see Debugging Empty Vector Tiles. - Layer names drifting from the style. The renderer matches
source-layerby exact string. Keep the names in one constant shared by the SQL and the style, and assert on the decoded layer set in tests. - Duplicated attribute dictionaries. Each layer carries its own keys and values tables, so a shared attribute repeated across six layers is stored six times. Another reason to keep layer counts moderate.
- One slow layer holding the statement. The combined query is as slow as its slowest CTE. Profile per layer before assuming the tile is uniformly expensive, and consider materialized views for the one that dominates.
- Cache invalidation across mixed cadences. A combined tile expires at the shortest lifetime of any layer inside it. If one layer changes every minute, split it out rather than dragging the others down.
Deciding what belongs in the same tile
The grouping decision is about change rate and audience, not about what looks tidy in the style file. Two layers belong together when they are always drawn together and change on similar timescales; they belong apart when either of those breaks.
Road geometry and building footprints change monthly at most, are drawn on every request, and share a cache lifetime measured in hours — one tile. Live vehicle positions change every few seconds and would drag that hours-long lifetime down to nothing, so they belong in their own endpoint with its own short max-age, layered client-side over the base tile.
Access control is the second splitter. If one layer is public and another requires a scope check, keeping them in the same tile means the tile itself becomes privileged and the public layer stops being cacheable at the edge. Split by sensitivity so the public half can be served from a shared cache and only the restricted half carries a per-caller cache key — the pattern described in JWT Authentication for Spatial Scopes.
A useful test when in doubt: if you would ever want to invalidate one layer without the other, they are two tiles.
Verification Snippet
import mapbox_vector_tile, requests
r = requests.get("http://localhost:8000/v1/tiles/14/8188/5448.mvt")
tile = mapbox_vector_tile.decode(r.content)
assert set(tile) == {"road", "building", "label"}, set(tile)
for name, layer in tile.items():
print(f"{name:9} {len(layer['features']):5} features extent={layer['extent']}")
# road 1180 features extent=4096
# building 842 features extent=4096
# label 37 features extent=4096-- Per-layer byte contribution for one tile, to find what to thin
SELECT 'road' AS layer, octet_length((SELECT ST_AsMVT(r.*, 'road', 4096, 'geom') FROM roads r)) AS bytes
UNION ALL
SELECT 'building', octet_length((SELECT ST_AsMVT(b.*, 'building', 4096, 'geom') FROM buildings b));Related
- Vector Tile Endpoints with ST_AsMVT — the single-layer query this extends
- Simplifying Geometry Per Zoom Level for Vector Tiles — the tolerance each layer applies
- Cloudflare Workers Edge Routing for Vector Tile Endpoints — serving the combined tile from the edge
← Back to Vector Tile Endpoints with ST_AsMVT