← Back to Vector Tile Endpoints with ST_AsMVT
This page is a diagnostic order of operations for the most frustrating failure in a tile service: the request succeeds, the response is well-formed, and the map shows nothing.
Context & When to Use
Empty tiles are hard because every layer of the stack reports success. PostGIS returns a row, ST_AsMVT returns a valid protobuf, FastAPI returns 200, the renderer parses the tile and draws its zero features without complaint. There is no error anywhere, so the usual instinct — read the logs — produces nothing.
The cause is almost always one of five things, and they are worth checking in a fixed order because each is cheaper to test than the next. Four of the five are silent by design; only the tile-range error announces itself. Working from the database outward means you stop at the first layer that disagrees with your expectation, instead of rewriting the query and hoping.
Use this when a previously working tile route goes blank after a change, when tiles work at one zoom but not another, or when a new deployment renders nothing while the old one is fine. The full query this page dissects is on the ST_AsMVT topic page.
Runnable Implementation
One query answers the first three questions at once. Run it against the tile that is blank, substituting your own z/x/y.
-- Diagnostic breakdown for tile 14/8188/5448
WITH bounds AS (
SELECT ST_TileEnvelope(14, 8188, 5448) AS merc,
ST_Transform(ST_TileEnvelope(14, 8188, 5448), 4326) AS wgs
),
candidates AS (
SELECT f.id,
f.min_zoom,
ST_SRID(f.geom) AS src_srid,
ST_AsMVTGeom(ST_Transform(f.geom, 3857),
b.merc, 4096, 64, true) AS clipped
FROM features f
CROSS JOIN bounds b
WHERE f.geom && b.wgs -- the index filter, on its own
)
SELECT count(*) AS candidates,
count(*) FILTER (WHERE clipped IS NULL) AS clipped_to_null,
count(*) FILTER (WHERE min_zoom > 14) AS gated_out_by_zoom,
count(DISTINCT src_srid) AS distinct_source_srids,
min(src_srid) AS a_source_srid,
ST_SRID((SELECT merc FROM bounds)) AS bounds_srid
FROM candidates;Read the row like a decision tree. candidates = 0 means the problem is upstream of the tile encoder — either there is genuinely no data here or the envelope is wrong. candidates > 0 with clipped_to_null = candidates is the SRID mismatch, confirmed by a_source_srid and bounds_srid disagreeing. gated_out_by_zoom equal to the candidate count is the min_zoom filter doing exactly what it was told.
Key Parameters & Options
| Check | Query fragment | What a bad value looks like |
|---|---|---|
| Candidate count | WHERE geom && bounds_wgs | 0 when data exists elsewhere → envelope wrong |
| Source SRID | ST_SRID(f.geom) | Anything other than the storage SRID |
| Bounds SRID | ST_SRID(ST_TileEnvelope(…)) | Always 3857; mismatch with the geometry is the classic bug |
| Clip survivors | count(*) FILTER (WHERE clipped IS NOT NULL) | 0 with candidates > 0 |
| Zoom gate | min_zoom > z | Equal to the candidate count |
| Tile range | x, y < 2^z | Raises Tile coordinates are out of range |
The tile-range check is the only one that errors rather than emptying, which is why it belongs last in the list and first in the route’s own validation.
Reading the failure signature at a glance
Each cause leaves a different fingerprint across zoom levels, which is often faster to read than the SQL.
Gotchas & Failure Modes
ST_Transformapplied to the bounds instead of the geometry. Both need to end up in 3857 forST_AsMVTGeom; transforming the envelope into 4326 and passing it asboundsproduces the same silent all-NULL clip.WHERE geom IS NOT NULLomitted after clipping. The tile then contains features with null geometry, which some renderers reject outright and others draw as nothing — a blank tile with a non-zero byte count.- Buffer of zero at tile seams. Features that only touch the tile edge clip away entirely. If the blankness is confined to boundary tiles, the
bufferargument is the culprit. - A
LIMITinside the tile CTE. Combined with an unordered scan, a limit can select rows from an unrelated area and clip them all away. Never limit inside a tile query; gate withmin_zoominstead. - 204 responses cached as permanent. A long
max-ageon an empty tile keeps the hole after the data arrives. Use a shortmax-agefor 204 and a long one only for tiles with content. - Testing against a replica that has not caught up. A read replica lagging behind a bulk import returns genuinely empty tiles. Check
pg_last_xact_replay_timestamp()before assuming a code bug.
Making the next blank tile easier to diagnose
Every one of these causes is silent because the pipeline treats “no features” as an ordinary outcome. A few cheap additions turn that silence into a signal, and they cost nothing on the happy path.
Emit a counter for empty responses, labelled by zoom but never by tile coordinate — x and y are unbounded cardinality and would flood the metrics backend. A sudden broad rise across all zoom levels is a deployment bug; a stable baseline concentrated in one region is ocean. The instrumentation patterns in Observability for Spatial Endpoints apply directly.
Attach the diagnostic counts to the trace span when the tile comes back empty: candidates found, rows clipped to null, rows gated by zoom. Three integers on a span that is only created for empty tiles cost almost nothing and mean the next incident is answered from the trace rather than by reproducing the query by hand.
Finally, assert on a known-populated tile in the test suite. A single test that fetches one tile and asserts a non-zero feature count catches the SRID mismatch, the null-clip bug and the missing WHERE geom IS NOT NULL in one go — all three of which pass every type check and every linter.
Verification Snippet
# Byte length tells you empty from broken faster than any decoder
curl -s -o /dev/null -w 'status=%{http_code} bytes=%{size_download}\n' \
localhost:8000/v1/tiles/14/8188/5448.mvt
# status=200 bytes=96412 → tile has content
# status=204 bytes=0 → deliberately empty
# status=200 bytes=0 → suspicious: fix the route to send 204import mapbox_vector_tile, requests
r = requests.get("http://localhost:8000/v1/tiles/14/8188/5448.mvt")
decoded = mapbox_vector_tile.decode(r.content) if r.content else {}
print({name: len(layer["features"]) for name, layer in decoded.items()})
# {'features': 1180} → healthy
# {} → empty protobuf; run the diagnostic query aboveRelated
- Vector Tile Endpoints with ST_AsMVT — the query these checks dissect
- Coordinate Reference Systems & SRID Handling — why the SRID mismatch is silent
- Caching Vector Tiles at the Edge with Cache-Control — busting a cached empty tile
← Back to Vector Tile Endpoints with ST_AsMVT