Fixing a Tile Endpoint That Returns 404 or Blank Tiles

On this page

Problem statement

The map shows a grey grid. The network panel shows 404s, or 200s with empty bodies, or 200s with bodies that render nothing.

Those are four different failures with the same symptom, and the first job is to tell them apart:

  • 404 for every tile β€” the URL template, the path, or the tile scheme is wrong.
  • 404 for some tiles β€” the requests are outside the data's extent or the server's zoom range, which may be correct.
  • 200 with an empty body β€” the query matched nothing at that tile. Often correct, sometimes a CRS mismatch.
  • 200 with a body that renders nothing β€” the tile contains features under a layer name the style does not reference, or the geometry was dropped in preparation.

Only the first is unambiguously a bug. The others need one measurement each to separate a correct empty tile from a broken pipeline.

Quick answer

Request one tile you know contains data, and look at what comes back. Against the Leiden buildings service in Example 3, running locally:

import httpx

for label, path in (("central Leiden", "/tiles/v1/14/8396/5400.mvt"),
                    ("the North Sea", "/tiles/v1/14/8328/5379.mvt"),
                    ("zoom 18", "/tiles/v1/18/134343/86407.mvt")):
    response = httpx.get(BASE + path)
    print(f"{label:15} {response.status_code} {len(response.content):7,} bytes")
central Leiden  200 319,441 bytes
the North Sea   204       0 bytes
zoom 18         404      35 bytes
  • 200 with data β€” the problem is client-side.
  • 204, no body β€” the server produced nothing for this tile.
  • 404 β€” wrong URL, or outside the served range.

Then check the tile coordinates are the ones you think:

import math


def lonlat_to_tile(lon, lat, z):
    n = 2 ** z
    x = int((lon + 180.0) / 360.0 * n)
    y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
    return z, x, y


print(lonlat_to_tile(4.4938, 52.1585, 14))      # central Leiden
(14, 8396, 5400)

A good share of "my tiles are 404" reports turn out to be a client requesting tiles for a place, or a zoom, the data does not cover.

Four causes of a grey tile grid, and how to tell them apart. four failures, one symptomwhat comes backwhat it means404 for every tileURL template, path, or the XYZ/TMSscheme404 for some tilesoutside the extent or the zoomrange β€” may be correct200 with an empty bodynothing there, or a CRS mismatcheverywhere200 with data, blank mapthe style's source-layer does notmatch the tileOnly the first is unambiguously a bug spatialworkflow.io
Probe one tile over known data and one over open ocean; compare.

Scheme, bounds, emptiness, CRS and layer names

1. Check the tile scheme

XYZ and TMS number the Y axis in opposite directions: y_tms = 2^z - 1 - y_xyz. A map using one against a server using the other produces tiles that exist, are the wrong place, and look like a vertically mirrored map β€” or 404s near the poles.

def flip_y(z, y):
    return 2 ** z - 1 - y


flipped = flip_y(14, 5400)
response = httpx.get(f"{BASE}/tiles/v1/14/8396/{flipped}.mvt")
print(f"14/8396/{flipped}: {response.status_code} {len(response.content)} bytes β€” the southern hemisphere")
14/8396/10983: 204 0 bytes β€” the southern hemisphere

If your tiles look mirrored top to bottom, this is the entire diagnosis.

2. Check the bounds and the zoom range

def tile_is_valid(z, x, y, min_zoom=0, max_zoom=16):
    if not min_zoom <= z <= max_zoom:
        return False, f"zoom {z} is outside {min_zoom}–{max_zoom}"
    limit = 2 ** z
    if not (0 <= x < limit and 0 <= y < limit):
        return False, f"tile {x},{y} is outside 0–{limit - 1} at zoom {z}"
    return True, "valid"


print(tile_is_valid(14, 8396, 5400), tile_is_valid(18, 134343, 86407), tile_is_valid(3, 9, 2))
(True, 'valid') (False, 'zoom 18 is outside 0–16') (False, 'tile 9,2 is outside 0–7 at zoom 3')

A server that 404s outside its zoom range is behaving correctly, and a client that requests zoom 18 from a service capped at 16 will see nothing. Set maxzoom in the map's source definition, and let the client overzoom the deepest level rather than requesting tiles that do not exist.

3. Distinguish an empty tile from a broken one

An empty tile is normal: most tiles in most pyramids contain no data. The question is whether the tiles you expect to contain data are empty.

def probe_tiles(base_url, samples):
    import httpx
    for z, x, y in samples:
        response = httpx.get(base_url.format(z=z, x=x, y=y))
        print(f"{z}/{x}/{y}: {response.status_code} "
              f"{len(response.content):,} bytes")


probe_tiles(BASE + "/tiles/v1/{z}/{x}/{y}.mvt", [(14, 8396, 5400), (14, 8328, 5379)])
14/8396/5400: 200 319,441 bytes
14/8328/5379: 204 0 bytes

Probe a tile over known data and a tile over open ocean. Data tile empty and ocean tile empty means the pipeline is broken; data tile full and ocean tile empty means it is working.

4. Check the CRS, which is the commonest silent cause

Tile schemes are defined in EPSG:3857. If the source data is in EPSG:4326 or a national grid and the tile query does not transform it, the geometry is nowhere near the tile bounds and every tile is empty β€” with no error anywhere.

-- correct: transform the bounds to the data's CRS for the index-friendly filter,
-- and the data to 3857 for the tile
select st_asmvtgeom(st_transform(t.geom, 3857), bounds.geom, 4096, 64, true)
from features t, (select st_tileenvelope(:z, :x, :y) as geom) bounds
where t.geom && st_transform(bounds.geom, 4326);

Run against the Leiden buildings, which are stored in EPSG:4326, with each transform in turn left out:

import psycopg

QUERY = """
with bounds as (select st_tileenvelope(14, 8396, 5400) as geom),
     mvtgeom as (
       select st_asmvtgeom({data}, bounds.geom, 4096, 64, true) as geom
       from features t, bounds where {filter})
select count(*), length(st_asmvt(mvtgeom.*, 'buildings', 4096, 'geom'))
from mvtgeom where geom is not null"""
with psycopg.connect(DSN) as con:
    for label, data, where in (
            ("both transforms", "st_transform(t.geom, 3857)", "t.geom && st_transform(bounds.geom, 4326)"),
            ("filter untransformed", "st_transform(t.geom, 3857)", "st_transform(t.geom, 3857) && st_transform(bounds.geom, 4326)"),
            ("data untransformed", "t.geom", "t.geom && st_transform(bounds.geom, 4326)")):
        count, size = con.execute(QUERY.format(data=data, filter=where)).fetchone()
        print(f"{label:22} {count:5,} features, {size or 0:7,} bytes")
both transforms        11,112 features, 274,686 bytes
filter untransformed       0 features,       0 bytes
data untransformed         0 features,       0 bytes

Getting either transform wrong produces empty tiles, not an error: every query above ran without complaint.

5. Check the layer name in the tile against the style

A vector tile contains named layers. A MapLibre style that references "source-layer": "features" against a tile whose layer is called default renders nothing β€” with a 200, a non-zero body and no console error.

import mapbox_vector_tile


def tile_layers(path_or_bytes):
    body = (open(path_or_bytes, "rb").read()
            if isinstance(path_or_bytes, str) else path_or_bytes)
    decoded = mapbox_vector_tile.decode(body)
    for name, layer in decoded.items():
        print(f"layer {name!r}: {len(layer['features'])} features")
        if layer["features"]:
            print(f"  properties: {list(layer['features'][0]['properties'])[:6]}")
    return list(decoded)
tile_layers(httpx.get(BASE + "/tiles/v1/14/8396/5400.mvt").content)
layer 'buildings': 11112 features
  properties: ['building']

Compare the output with source-layer in the style. This is the single most common cause of "the tiles are fine and the map is empty".

6. Check for NULL geometry before aggregation

ST_AsMVTGeom returns NULL for geometry that vanishes at the tile's resolution. Rows with NULL geometry passed to ST_AsMVT produce features with no geometry, which render as nothing:

select st_asmvt(mvtgeom.*, 'features', 4096, 'geom')
from mvtgeom where geom is not null;      -- the filter is not optional

How many rows that filter removes depends on the zoom:

NULLS = """
with bounds as (select st_tileenvelope(%s, %s, %s) as geom)
select count(*), count(*) filter (where st_asmvtgeom(st_transform(t.geom, 3857), bounds.geom, 4096, 64, true) is null)
from features t, bounds where t.geom && st_transform(bounds.geom, 4326)"""
with psycopg.connect(DSN) as con:
    for z, x, y in ((10, 524, 337), (12, 2099, 1350), (14, 8396, 5400)):
        rows, nulls = con.execute(NULLS, (z, x, y)).fetchone()
        print(f"z{z}: {rows:6,} buildings in the tile, {nulls:5,} with NULL geometry at this resolution")
z10: 58,879 buildings in the tile, 25,845 with NULL geometry at this resolution
z12: 19,712 buildings in the tile,   170 with NULL geometry at this resolution
z14: 11,112 buildings in the tile,     0 with NULL geometry at this resolution
XYZ numbers the Y axis from the top; TMS numbers it from the bottom. y_tms = 2^z βˆ’ 1 βˆ’ y_xyzXYZy=0y=3y increases downwardTMSy=3y=0y increases upwardMixing them gives a vertically mirrored map, or 404s where the flipped tile is outside the data spatialworkflow.io
MBTiles stores TMS internally while serving XYZ externally.

Diagnosing Leiden's building tiles

The data used on this page

The tiles are served from Leiden's buildings in OpenStreetMap β€” about 59,000 footprints fetched with OSMnx through an Overpass mirror β€” loaded into a throwaway PostGIS database in EPSG:4326, the way most source data arrives. The tile service is Example 3, run with uvicorn. The OpenStreetMap answers are read as the Overpass mirror gave them on 10 October 2026.

import io
import os
import requests
import zipfile

import geopandas as gpd
import osmnx as ox

# OpenStreetMap as Overpass returned it on 10 October 2026, saved as osmnx's own cache so the counts
# below do not drift as OSM is edited; delete the cache folder to query live instead
if not os.path.exists("cache"):
    reply = requests.get("https://data.spatialworkflow.io/osm-cache-tile-endpoint-returns-404-or-blank-2026-10-10.zip", timeout=300)
    reply.raise_for_status()
    with zipfile.ZipFile(io.BytesIO(reply.content)) as archive:
        archive.extractall("cache")

ox.settings.overpass_url = "https://maps.mail.ru/osm/tools/overpass/api"   # overpass-api.de mirror
ox.settings.overpass_rate_limit = False
if not os.path.exists("buildings.parquet"):
    found = ox.features_from_place("Leiden, Netherlands", {"building": True}).reset_index()
    found = found[found.geom_type.isin(["Polygon", "MultiPolygon"])]
    keep = [c for c in ("osmid", "building", "name", "height", "building:levels") if c in found.columns]
    found[keep + ["geometry"]].rename(columns={"building:levels": "levels"}).to_parquet("buildings.parquet")
buildings = gpd.read_parquet("buildings.parquet")
print(f"{len(buildings):,} OSM buildings in Leiden, EPSG:{buildings.crs.to_epsg()}; "
      f"columns {[c for c in buildings.columns if c != 'geometry']}")
import subprocess
import time

import psycopg
from sqlalchemy import create_engine

if subprocess.run(["docker", "inspect", "guide-postgis"], capture_output=True).returncode != 0:
    subprocess.run(["docker", "run", "-d", "--name", "guide-postgis", "-e", "POSTGRES_USER=user",
                    "-e", "POSTGRES_PASSWORD=guide", "-e", "POSTGRES_DB=gis", "-p", "127.0.0.1:55433:5432",
                    "postgis/postgis:16-3.4"], check=True, capture_output=True)
DSN = "postgresql://user:guide@127.0.0.1:55433/gis"
for _ in range(120):
    try:
        with psycopg.connect(DSN) as con:
            con.execute("select postgis_version()")
        break
    except psycopg.OperationalError:
        time.sleep(1)
with psycopg.connect(DSN) as con:
    loaded = con.execute("select to_regclass('features') is not null").fetchone()[0]
if not loaded:
    engine = create_engine(DSN.replace("postgresql://", "postgresql+psycopg://"))
    buildings[["building", "geometry"]].rename_geometry("geom").to_postgis("features", engine)
    with psycopg.connect(DSN) as con:
        con.execute("create index on features using gist (geom)")
with psycopg.connect(DSN) as con:
    print(con.execute("select count(*), st_srid(min(geom)) from features").fetchone(), "rows and SRID in PostGIS")
58,879 OSM buildings in Leiden, EPSG:4326; columns ['building', 'name', 'height', 'levels']
(58879, 4326) rows and SRID in PostGIS

The service itself β€” Example 3 walks through its endpoint β€” is written to a file and started with uvicorn before anything else on the page runs:

%%writefile tile_service.py
import psycopg
from fastapi import FastAPI, HTTPException, Response

app = FastAPI()
DSN = "postgresql://user:guide@127.0.0.1:55433/gis"
MAX_ZOOM = 16
TILE_SQL = """
with bounds as (select st_tileenvelope(%(z)s, %(x)s, %(y)s) as geom),
     mvtgeom as (
       select st_asmvtgeom(st_transform(t.geom, 3857), bounds.geom, 4096, 64, true) as geom, t.building
       from features t, bounds
       where t.geom && st_transform(bounds.geom, 4326))
select st_asmvt(mvtgeom.*, 'buildings', 4096, 'geom') from mvtgeom where geom is not null"""


def build_tile(z, x, y):
    with psycopg.connect(DSN) as con:
        return con.execute(TILE_SQL, {"z": z, "x": x, "y": y}).fetchone()[0]


@app.get("/tiles/{version}/{z}/{x}/{y}.mvt")
def tile(version: str, z: int, x: int, y: int):
    if not 0 <= z <= MAX_ZOOM:
        raise HTTPException(404, f"zoom {z} outside 0–{MAX_ZOOM}")
    limit = 2 ** z
    if not (0 <= x < limit and 0 <= y < limit):
        raise HTTPException(404, f"tile {z}/{x}/{y} outside the pyramid")

    body = build_tile(z, x, y)
    if not body:
        # an empty tile is a valid answer, not an error
        return Response(b"", status_code=204,
                        headers={"Cache-Control": "public, max-age=3600"})

    return Response(body, media_type="application/vnd.mapbox-vector-tile",
                    headers={"Cache-Control": "public, max-age=3600"})
import httpx
import socket
import sys

with socket.socket() as probe:
    probe.bind(("127.0.0.1", 0))
    port = probe.getsockname()[1]
server = subprocess.Popen([sys.executable, "-m", "uvicorn", "tile_service:app", "--port", str(port),
                           "--log-level", "warning"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
BASE = f"http://127.0.0.1:{port}"
for _ in range(200):
    try:
        httpx.get(BASE + "/docs", timeout=1); break
    except httpx.TransportError:
        time.sleep(0.1)
print("serving", httpx.get(BASE + "/tiles/v1/14/8396/5400.mvt").headers["content-type"])
serving application/vnd.mapbox-vector-tile

Example 1 β€” a full tile diagnostic

import math
import httpx


def diagnose_tiles(url_template, lon, lat, zooms=(6, 10, 12, 14)):
    """Probe the pyramid over a point you know has data."""
    print(f"{'z/x/y':16} {'status':>6} {'bytes':>9}  note")
    for z in zooms:
        n = 2 ** z
        x = int((lon + 180.0) / 360.0 * n)
        y = int((1 - math.asinh(math.tan(math.radians(lat))) / math.pi) / 2 * n)
        response = httpx.get(url_template.format(z=z, x=x, y=y))
        size = len(response.content)

        note = ""
        if response.status_code == 404:
            note = "not served β€” check the zoom range and the URL"
        elif size == 0:
            note = "empty β€” no features here, or a CRS mismatch"
        elif size < 200:
            note = "suspiciously small"
        print(f"{f'{z}/{x}/{y}':16} {response.status_code:6} {size:9,}  {note}")

    flipped = 2 ** zooms[-1] - 1 - y
    alt = httpx.get(url_template.format(z=zooms[-1], x=x, y=flipped))
    if alt.status_code == 200 and len(alt.content) > 0:
        print(f"\n! the TMS-flipped tile {zooms[-1]}/{x}/{flipped} has data β€” "
              f"the server and client disagree about the Y axis")


diagnose_tiles(BASE + "/tiles/v1/{z}/{x}/{y}.mvt", 4.4938, 52.1585, zooms=(6, 10, 12, 14, 17))
z/x/y            status     bytes  note
6/32/21             200     2,599  
10/524/337          200   793,639  
12/2099/1350        200   523,551  
14/8396/5400        200   319,441  
17/67172/43201      404        35  not served β€” check the zoom range and the URL

The probe shows something else worth catching: the zoom-10 tile is 794 kB, because this service sends every building at every zoom. Tiles that large are a generalisation problem rather than a 404 one β€” generalisation per zoom is the fix β€” but they are the same kind of silent failure: the map loads, slowly, on a fast machine.

Example 2 β€” checking a tile's contents against a style

import json
import mapbox_vector_tile


def check_style_against_tile(tile_bytes, style_path):
    decoded = mapbox_vector_tile.decode(tile_bytes)
    tile_layers = set(decoded)
    print(f"tile contains layers: {sorted(tile_layers) or 'NONE'}")
    for name, layer in decoded.items():
        print(f"  {name}: {len(layer['features'])} features")

    style = json.load(open(style_path))
    referenced = {layer["source-layer"] for layer in style.get("layers", [])
                  if "source-layer" in layer}
    print(f"style references:     {sorted(referenced)}")

    missing = referenced - tile_layers
    unused = tile_layers - referenced
    if missing:
        print(f"\n! the style references {sorted(missing)}, which the tile "
              f"does not contain β€” this renders nothing, with no error")
    if unused:
        print(f"  (the tile also contains {sorted(unused)}, unreferenced)")
    return missing


json.dump({"version": 8, "sources": {"leiden": {"type": "vector", "tiles": [BASE + "/tiles/v1/{z}/{x}/{y}.mvt"]}},
           "layers": [{"id": "footprints", "type": "fill", "source": "leiden", "source-layer": "features"}]},
          open("style.json", "w"))
check_style_against_tile(httpx.get(BASE + "/tiles/v1/14/8396/5400.mvt").content, "style.json")
tile contains layers: ['buildings']
  buildings: 11112 features
style references:     ['features']

! the style references ['features'], which the tile does not contain β€” this renders nothing, with no error
  (the tile also contains ['buildings'], unreferenced)

Example 3 β€” serving an empty tile correctly

@app.get("/tiles/{version}/{z}/{x}/{y}.mvt")
def tile(version: str, z: int, x: int, y: int):
    if not 0 <= z <= MAX_ZOOM:
        raise HTTPException(404, f"zoom {z} outside 0–{MAX_ZOOM}")
    limit = 2 ** z
    if not (0 <= x < limit and 0 <= y < limit):
        raise HTTPException(404, f"tile {z}/{x}/{y} outside the pyramid")

    body = build_tile(z, x, y)
    if not body:
        # an empty tile is a valid answer, not an error
        return Response(b"", status_code=204,
                        headers={"Cache-Control": "public, max-age=3600"})

    return Response(body, media_type="application/vnd.mapbox-vector-tile",
                    headers={"Cache-Control": "public, max-age=3600"})

Returning 204 for an empty tile, with a cache header, is worth doing: clients handle it correctly, and the cache stops the server re-deriving nothing for a tile that will always be nothing.

server.terminate()
server.wait()
print("tile server stopped")
tile server stopped

Why most tiles are legitimately empty

Why most tiles are legitimately empty

A pyramid covers the whole world at every zoom level, and data does not. At zoom 12 there are 16.7 million tiles; a city's buildings occupy a handful of them.

So "empty tile" is the normal case and cannot be treated as an error. The diagnostic that works is comparative: a tile over known data and a tile over open ocean, and the interesting result is when both are empty.

Why a CRS mismatch produces empty tiles rather than an error

Tile bounds are computed in EPSG:3857. A spatial filter comparing those bounds against geometry in EPSG:4326 compares numbers in the millions against numbers in the tens β€” they do not intersect, and no engine treats that as an error.

Every tile therefore comes back empty, at every zoom, for every location, with a 200 status. The tell is uniformity: a broken pipeline is empty everywhere, while a working one is empty only where there is no data.

Why the layer name is the commonest client-side cause

A vector tile is a container of named layers, and a style references a layer by name in source-layer. The name comes from whatever produced the tile β€” ST_AsMVT's second argument, or tippecanoe's layer name β€” and it is easy for the two to drift apart.

The result is a valid tile, a valid style, a 200 response with a non-empty body, and nothing on the map. Decoding one tile and comparing its layer names against the style takes a minute and resolves it immediately.

Why the Y-axis convention still causes problems

XYZ, used by almost every web map today, numbers Y from the top. TMS, the older OGC convention, numbers it from the bottom. Some servers and file formats β€” MBTiles, notably β€” use TMS internally while serving XYZ externally.

The symptom is either a vertically mirrored map or 404s where the flipped coordinate falls outside the data. y_tms = 2^z - 1 - y_xyz converts between them, and probing the flipped tile is a one-line test.

Distinguishing an ordinary empty tile from a broken pipeline. uniform emptiness is the signatureworking serviceβœ“tile over data: has featuresβœ“tile over ocean: emptyβœ“most of the pyramid is emptyβœ“that is normalCRS mismatchβœ•tile over data: emptyβœ•tile over ocean: emptyβœ•every zoom, every locationβœ•200 status, no error anywhereTile bounds are EPSG:3857; if the data is not transformed the filter never matches spatialworkflow.io
Return 204 with a cache header for a genuinely empty tile, not a 404.

Empty-tile responses, MBTiles and client zoom limits

  • 204 is a better empty-tile response than 200 with an empty body, and much better than 404.
  • Cache empty tiles. They will be empty next time too.
  • MBTiles stores TMS internally β€” flip Y when serving XYZ.
  • Set maxzoom on the client source so it overzooms instead of requesting tiles you do not serve.
  • Bounds-check x and y, or bad requests run real queries.
  • A CRS mismatch is empty everywhere, which is what distinguishes it from ordinary emptiness.
  • ST_AsMVTGeom NULLs must be filtered before ST_AsMVT.
  • CORS failures look like missing tiles in a canvas renderer β€” check the console.

FAQ

Why do all my tiles return 404?

Usually the URL template, the zoom range or the tile scheme. Compute the tile coordinates for a known location and request that tile directly; if it 404s, compare against the TMS-flipped Y.

Is an empty tile an error?

No. Most tiles in any pyramid contain no data. Return 204 with a cache header, and worry only when the tiles you expect to contain data are also empty.

Why are all my tiles empty at every zoom?

Almost always a CRS mismatch: tile bounds are in EPSG:3857 and the data is not, so the filter never matches. Uniform emptiness is the signature.

The tiles have data but the map is blank. Why?

The style's source-layer does not match the layer name inside the tile. Decode one tile, list its layers, and compare with the style.

What is the difference between XYZ and TMS?

The Y axis direction: y_tms = 2^z - 1 - y_xyz. Mixing them gives a vertically mirrored map or 404s, and MBTiles stores TMS internally.

Should I return 404 or 204 for a tile with no data?

  1. A 404 makes clients retry and log errors, and it conflates "nothing here" with "wrong URL".