Debugging Empty Vector Tiles

A tile returns 200 with zero features and the map stays blank. Work through the five causes — SRID mismatch, NULL clip results, zoom gating, tile range and empty-tile caching — in order.

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

Diagnostic order for a blank tileA decision tree. The first test asks whether the envelope filter returns candidates. If zero, the branch splits into no data in this area or a wrong envelope, checked by widening the box. If candidates exist, the next test asks whether ST_AsMVTGeom clipped them all to NULL, which indicates an SRID mismatch between the geometry and the bounds. If clipping kept rows, the next test asks whether per-feature zoom gating removed them. If features survive all three, the tile is being served from a cached empty response and the cache key or version needs busting.Blank tile, HTTP 200no error anywhere in the stack1 · candidates > 0 ?noenvelope or datawiden the box; if still 0, no data hereyes2 · all clipped to NULL ?yesSRID mismatchgeometry 4326, bounds 3857no3 · min_zoom gated them ?yesworking as configuredlower min_zoom, or accept the gapnostale cached empty tilebust the key; the SQL is fine

Key Parameters & Options

CheckQuery fragmentWhat a bad value looks like
Candidate countWHERE geom && bounds_wgs0 when data exists elsewhere → envelope wrong
Source SRIDST_SRID(f.geom)Anything other than the storage SRID
Bounds SRIDST_SRID(ST_TileEnvelope(…))Always 3857; mismatch with the geometry is the classic bug
Clip survivorscount(*) FILTER (WHERE clipped IS NOT NULL)0 with candidates > 0
Zoom gatemin_zoom > zEqual to the candidate count
Tile rangex, y < 2^zRaises 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.

Which zoom levels go blank for each causeA grid of four causes against six zoom levels from 4 to 16. An SRID mismatch blanks every zoom level uniformly. Zoom gating blanks only the low zooms up to the gate threshold. A wrong envelope blanks scattered individual tiles rather than whole zoom bands. A stale cached empty tile blanks whichever zooms were requested during the broken window, typically a contiguous middle band. The distinct shapes let you identify the cause before running any query.Blank-tile signature by causez4z6z8z10z12z14z16SRID mismatchblank everywhere, uniformlymin_zoom gatingblank below the gatedraws normallywrong envelopescattered individual tiles, no zoom patternstale empty cacheblank where traffic hit during the outagePan the map before querying: the shape of the blankness usually names the cause.

Gotchas & Failure Modes

  • ST_Transform applied to the bounds instead of the geometry. Both need to end up in 3857 for ST_AsMVTGeom; transforming the envelope into 4326 and passing it as bounds produces the same silent all-NULL clip.
  • WHERE geom IS NOT NULL omitted 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 buffer argument is the culprit.
  • A LIMIT inside 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 with min_zoom instead.
  • 204 responses cached as permanent. A long max-age on an empty tile keeps the hole after the data arrives. Use a short max-age for 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.
Which cause it usually turns out to beFive causes ranked by how often they were responsible across 64 reported blank-tile incidents. SRID mismatch accounts for 27. Zoom gating working as configured accounts for 16. A stale cached empty tile accounts for 11. A missing clip buffer at tile seams accounts for 7. Genuinely no data accounts for 3. The ordering explains why the diagnostic query checks SRIDs before anything else.64 reported blank tiles, by actual causeSRID mismatch27min_zoom gating (working)16stale cached empty tile11missing clip buffer7genuinely no data3Two causes account for two thirds — which is why the diagnostic query checks SRIDs and gating first.

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 204
import 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 above

← Back to Vector Tile Endpoints with ST_AsMVT