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.
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
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.
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
maxzoomon the client source so it overzooms instead of requesting tiles you do not serve. - Bounds-check
xandy, or bad requests run real queries. - A CRS mismatch is empty everywhere, which is what distinguishes it from ordinary emptiness.
ST_AsMVTGeomNULLs must be filtered beforeST_AsMVT.- CORS failures look like missing tiles in a canvas renderer β check the console.
Internal links
- How to serve vector tiles from PostGIS with ST_AsMVT β the server side
- Fixing a web map layer that does not appear β the wider client-side diagnosis
- Fixing tiles that are offset or misplaced β the TMS and CRS variants
- Web map tiles explained β the scheme and the numbering
- Vector tiles explained β layers inside a tile
- Fixing CORS errors on a spatial API β when the tile is fetched and discarded
- Fixing blank or grey tiles in an embedded map β the app-side symptom
- Dynamic tile server or static tiles: what to serve β the serving decision
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?
- A 404 makes clients retry and log errors, and it conflates "nothing here" with "wrong URL".