Fixing Blank or Grey Tiles in an Embedded Map
On this page
Problem statement
The app renders, the map container is the right size, and it is a grey grid. Or the basemap appears and your data does not. Or it works locally and not in production.
For an embedded map β folium, pydeck, MapLibre inside Streamlit or Dash β there are seven causes, and the browser's console and network panel distinguish them in about a minute:
- the tile URL is wrong, or needs a key that is not being sent
- the provider now wants a key and says so in the tile β a 200 response whose picture reads "API key required"
- mixed content β an HTTPS page requesting HTTP tiles
- CORS β the tiles load as images and fail when a canvas renderer reads them
- the data layer is outside the view, usually a CRS problem
- the layer is there and invisible β zero opacity, zero radius, or a style that draws nothing
- the container has no height, so the map is a zero-pixel element
The second one is not hypothetical. folium's built-in cartodbpositron basemap β the one this page used to recommend β now returns the same 2,049-byte "API KEY REQUIRED" tile for every request, with status 200, so nothing in the network panel looks wrong.
Quick answer
Work down this list in the browser's developer tools:
1. Console any error at all? mixed content and CORS both appear here
2. Network are tile requests being made? what status do they return?
3. Network is the data request being made, and what size is the response?
4. Elements does the map container have a non-zero height?
5. App does the layer's style draw anything at this zoom?
# the two most common fixes
folium.Map(tiles="https://β¦/{z}/{x}/{y}.png", # https, not http
attr="Β© OpenStreetMap contributors") # required for a custom tile URL
st_folium(m, height=560) # an explicit height
A folium map with a custom tiles URL and no attr raises ValueError: Custom tiles must have an attribution. st_folium already defaults to 700 px, so the height matters less there than in a hand-built Leaflet page or a Dash layout, where a map in a container with no height renders as nothing at all.
Console, tile requests, container height, data layer
1. Check the console first
These are the messages headless Chromium printed when the cases below were reproduced β a folium map served from an HTTPS origin, and a tile server with no CORS headers:
Mixed Content: The page at 'https://app.example.org/' was loaded over HTTPS, but
requested an insecure resource 'http://tile.openstreetmap.org/0/0/0.png'. This
request has been blocked; the content must be served over HTTPS.
Access to image at 'http://127.0.0.1:8766/tile.png' from origin
'http://localhost:8765' has been blocked by CORS policy: No
'Access-Control-Allow-Origin' header is present on the requested resource.
Mixed content behaves differently for the two kinds of map. Leaflet β and so folium β draws tiles as <img> elements, and Chromium quietly upgrades those to HTTPS with only a console warning ("This request was automatically upgraded to HTTPS"): an http://tile.openstreetmap.org basemap still drew, because the same host answers on HTTPS. MapLibre and deck.gl fetch their tiles, and a fetch is blocked outright with the error above; the network panel lists it with the status (blocked:mixed-content). So an HTTP tile URL can work in a folium prototype and fail the day the same URL moves into a MapLibre app. The fix either way is the HTTPS URL.
2. Check whether tiles are being requested, and what they return
In the network panel, filter for the tile path:
- no requests β the layer was never added
- 404 β the URL template, the zoom range, or the XYZ/TMS scheme
- 401 or 403 β an API key is missing, wrong, or restricted to another domain (without a key, MapTiler answered 403 and Stadia 401 β each with an error image as the body)
- 429 β a rate limit; public tile servers have usage policies
- 200, and every tile the same size β a placeholder. Carto's is 2,049 bytes; Thunderforest's is a real 61 KB map with "API Key Required" printed across it, so size alone does not give it away
A 200 does not prove the URL is right either. Esri's World Imagery template is {z}/{y}/{x}, row before column; written as {z}/{x}/{y} it still answered 200, with a picture of somewhere else.
A 403 from a keyed provider in production and not locally is usually a domain restriction on the key.
3. Give the container an explicit height
Leaflet and deck.gl size themselves from their container. In a dashboard layout the container frequently has no intrinsic height, so the map is zero pixels tall and renders as nothing:
st_folium(m, height=560, width=None) # explicit height (the default is 700)
st.pydeck_chart(deck, height=500) # 500 is also its default
.folium-map { height: 560px !important; }
The tell is an empty area rather than grey tiles: the map element exists in the DOM and has no size. It is not a quiet network panel, though β a Leaflet map in a flex column with no height measured 0 px tall and still requested five tiles, all of which returned 200.
4. Check the data layer separately from the basemap
If the basemap draws and your data does not, the layer is present and not visible. Three usual causes:
- the CRS is wrong. Web maps need EPSG:4326 for folium and deck.gl; a layer in a national grid has coordinates in the hundreds of thousands and lands nowhere near the view.
- the geometry is empty or null.
gdf.geometry.is_empty.sum()and.isna().sum()before rendering. - the style draws nothing β
radiusin metres at a low zoom,fillOpacity=0, or a colour expression that evaluates to fully transparent.
assert gdf.crs.to_epsg() == 4326, f"reproject: layer is {gdf.crs}"
assert not gdf.geometry.is_empty.all(), "every geometry is empty"
print(gdf.total_bounds) # is this near the map's view?
5. Fit the view to the data rather than guessing
A hard-coded centre and zoom is the reason a correct layer is off screen:
minx, miny, maxx, maxy = gdf.total_bounds
m = folium.Map()
m.fit_bounds([[miny, minx], [maxy, maxx]])
fit_bounds takes [[south, west], [north, east]] β latitude first, unlike the bounds tuple, which is a transposition waiting to happen.
6. Check the tile provider's terms before blaming the code
Public tile servers have usage policies, and the OpenStreetMap Foundation's tiles in particular are not for embedding in applications at volume. A 429, an error tile, or a sudden block is enforcement, not a bug.
For anything beyond development, use a provider with a plan, or serve your own tiles.
A preflight for an embedded UK map
The data used on this page
The United Kingdom from Natural Earth's 1:50m countries (public domain), once in longitude/latitude and once in the British National Grid, which is how a layer straight out of a national dataset arrives. The tile checks go to the real providers.
import io
import zipfile
from pathlib import Path
import geopandas as gpd
import requests
if not Path("ne_50m_admin_0_countries.shp").exists():
zipfile.ZipFile(io.BytesIO(requests.get(
"https://naciscdn.org/naturalearth/50m/cultural/ne_50m_admin_0_countries.zip",
timeout=120).content)).extractall(".")
uk = gpd.read_file("ne_50m_admin_0_countries.shp").query("ADMIN == 'United Kingdom'")[["ADMIN", "geometry"]]
uk_bng = uk.to_crs(27700)
uk.to_file("uk.gpkg")
print(uk.total_bounds.round(2), uk_bng.total_bounds.round(0))
[-8.14 50.02 1.75 60.83] [ 1935. 18456. 654566. 1217133.]
Example 1 β a preflight for an embedded map
import geopandas as gpd
def map_preflight(gdf: gpd.GeoDataFrame, tile_url: str | None = None,
container_height: int | None = None):
problems = []
if gdf.crs is None:
problems.append("the layer has no CRS")
elif gdf.crs.to_epsg() != 4326:
problems.append(f"the layer is {gdf.crs.to_string()}; web maps need "
f"EPSG:4326 β call .to_crs(4326)")
empty = int(gdf.geometry.is_empty.sum() + gdf.geometry.isna().sum())
if empty:
problems.append(f"{empty} empty or null geometries")
if len(gdf) == 0:
problems.append("the layer has no features β is the filter too narrow?")
if len(gdf):
minx, miny, maxx, maxy = gdf.total_bounds
if not (-180 <= minx <= 180 and -90 <= miny <= 90):
problems.append(f"bounds {gdf.total_bounds} are outside lon/lat "
f"range β wrong CRS, or coordinates transposed")
if tile_url and tile_url.startswith("http://"):
problems.append("the tile URL is http; an https page will block it "
"as mixed content")
if container_height is not None and container_height <= 0:
problems.append("the map container has no height β it will render as "
"nothing")
for problem in problems:
print(f" ! {problem}")
return problems
for label, layer, tiles in (("lon/lat, https tiles", uk, "https://tile.openstreetmap.org/{z}/{x}/{y}.png"),
("national grid, http tiles", uk_bng, "http://tile.openstreetmap.org/{z}/{x}/{y}.png")):
print(label)
print(" ->", len(map_preflight(layer, tiles, container_height=560)), "problems")
lon/lat, https tiles
-> 0 problems
national grid, http tiles
! the layer is EPSG:27700; web maps need EPSG:4326 β call .to_crs(4326)
! bounds [ 1934.55244748 18456.25704728 654566.21042675 1217132.86004744] are outside lon/lat range β wrong CRS, or coordinates transposed
! the tile URL is http; an https page will block it as mixed content
-> 3 problems
The national-grid layer trips both the CRS check and the bounds check. The bounds check is the one that still fires when the CRS label is wrong β a layer tagged 4326 whose numbers are metres β which is the case the CRS check cannot see.
Example 2 β a map that fits its data and declares its height
Streamlit runs a script, not a cell, so the app is written to a file and started as a server:
%%writefile map_app.py
import folium
import geopandas as gpd
import streamlit as st
from streamlit_folium import st_folium
def safe_map(gdf, tiles="OpenStreetMap", height=560):
if gdf.crs is None or gdf.crs.to_epsg() != 4326:
gdf = gdf.to_crs(4326)
gdf = gdf[~gdf.geometry.isna() & ~gdf.geometry.is_empty]
if gdf.empty:
st.warning("Nothing to draw β the filter matched no features.")
return None
m = folium.Map(tiles=tiles)
folium.GeoJson(gdf.to_json(),
style_function=lambda _: {"color": "#0ea5e9", "weight": 1,
"fillOpacity": 0.4}).add_to(m)
minx, miny, maxx, maxy = gdf.total_bounds
m.fit_bounds([[miny, minx], [maxy, maxx]]) # south,west then north,east
return st_folium(m, height=height, width=None,
returned_objects=["last_active_drawing"])
safe_map(gpd.read_file("uk.gpkg").to_crs(27700)) # handed a national-grid layer on purpose
Started under Streamlit and opened in headless Chromium, it reprojects the layer, fits the view, and draws at the height it asked for:
import subprocess
import sys
import time
from playwright.sync_api import sync_playwright
server = subprocess.Popen([sys.executable, "-m", "streamlit", "run", "map_app.py",
"--server.headless", "true", "--server.port", "8599"],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
time.sleep(6)
with sync_playwright() as p:
browser = p.chromium.launch()
page = browser.new_page(viewport={"width": 1200, "height": 900})
tiles = []
page.on("response", lambda r: tiles.append(r.status) if "tile.openstreetmap" in r.url else None)
page.goto("http://localhost:8599/")
frame_el = page.wait_for_selector("iframe", timeout=60_000)
page.wait_for_timeout(5000)
frame = frame_el.content_frame()
print("iframe height:", frame_el.bounding_box()["height"])
print("polygons drawn:", frame.locator("path.leaflet-interactive").count())
print("zoom after fit_bounds:", frame.evaluate(
"() => { for (const k in window) if (k.startsWith('map_')) return window[k].getZoom(); }"))
print("tile responses:", len(tiles), "all 200:", set(tiles) == {200})
browser.close()
finally:
server.terminate()
iframe height: 560
polygons drawn: 1
zoom after fit_bounds: 5
tile responses: 18 all 200: True
Example 3 β checking a tile URL from Python
import hashlib
import math
import httpx
def check_tiles(url_template, lon, lat, zooms=(4, 8, 12), headers=None):
"""Request real tiles and report what the server says."""
print(f" {'z/x/y':12} {'status':>6} {'bytes':>7} note")
digests = set()
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)
url = url_template.format(z=z, x=x, y=y, s="a")
try:
response = httpx.get(url, headers=headers or {}, timeout=15,
follow_redirects=True)
except httpx.HTTPError as exc:
print(f" {f'{z}/{x}/{y}':12} {'---':>6} {'---':>7} {exc}")
continue
digests.add(hashlib.sha1(response.content).hexdigest())
note = ""
if response.status_code in (401, 403):
note = "key missing, invalid, or domain-restricted"
elif response.status_code == 404:
note = "wrong template, zoom range, or TMS/XYZ scheme"
elif response.status_code == 429:
note = "rate limited β check the provider's usage policy"
print(f" {f'{z}/{x}/{y}':12} {response.status_code:6} "
f"{len(response.content):7,} {note}")
if len(zooms) > 1 and len(digests) == 1:
print(" ! every tile is byte-identical β a placeholder, not a map")
if url_template.startswith("http://"):
print(" ! http tiles are blocked or upgraded on an https page")
Three zoom levels over Bristol, against the real providers:
PROVIDERS = {
"OpenStreetMap": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
"Carto Positron (folium's cartodbpositron)":
"https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png",
"MapTiler, no key": "https://api.maptiler.com/maps/streets-v2/{z}/{x}/{y}.png",
"Stadia, no key": "https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png",
}
UA = {"User-Agent": "spatialworkflow.io tile check"} # OSM's policy asks for one
for name, template in PROVIDERS.items():
print(name)
check_tiles(template, -2.59, 51.45, headers=UA)
OpenStreetMap
z/x/y status bytes note
4/7/5 200 9,946
8/126/85 200 52,347
12/2018/1363 200 41,044
Carto Positron (folium's cartodbpositron)
z/x/y status bytes note
4/7/5 200 2,049
8/126/85 200 2,049
12/2018/1363 200 2,049
! every tile is byte-identical β a placeholder, not a map
MapTiler, no key
z/x/y status bytes note
4/7/5 403 10,260 key missing, invalid, or domain-restricted
8/126/85 403 10,260 key missing, invalid, or domain-restricted
12/2018/1363 403 10,260 key missing, invalid, or domain-restricted
! every tile is byte-identical β a placeholder, not a map
Stadia, no key
z/x/y status bytes note
4/7/5 401 14,885 key missing, invalid, or domain-restricted
8/126/85 401 14,885 key missing, invalid, or domain-restricted
12/2018/1363 401 14,885 key missing, invalid, or domain-restricted
! every tile is byte-identical β a placeholder, not a map
Running this from Python separates a tile-server problem from a browser problem in a few seconds, and it can go in the test suite. The identical-bytes test is what catches the Carto placeholder; a status check alone passes it.
Mixed content, zero-height containers and keyed tiles
Why mixed content depends on the map library
An HTTPS page may not load active content β a fetch, a script β over HTTP, so the browser refuses the request before it is sent and lists it as blocked. Images were once allowed through with a warning; Chromium now rewrites their URL to HTTPS instead and loads that. Leaflet's tiles are images, MapLibre's and deck.gl's are fetches.
That is why the same HTTP tile URL works in one stack and not the other, and why the upgrade is no rescue for a tile host that has no HTTPS at all: the upgraded request simply fails. Every serious tile provider offers HTTPS, and the fix is one character in the URL.
Why a container with no height is the commonest dashboard cause
Leaflet and deck.gl compute their size from the element they mount into. In a page with an explicit layout that is fine; in a dashboard with flexible columns the container frequently has no intrinsic height, so the map is zero pixels tall.
Nothing errors. The map initialises, requests a few tiles around its centre β they all succeed β and renders as an empty region, which reads as a broken map rather than as a layout problem.
Why the basemap can draw while your data does not
They are independent layers with independent failure modes. The basemap comes from a URL and appears whenever that URL works; your data comes from the app and appears whenever it is in the view and styled visibly.
So "basemap yes, data no" narrows the problem immediately to the data layer: its CRS, its geometry, or its style. Printing gdf.total_bounds and comparing with the map's view resolves most of those in one line.
Why a keyed provider fails only in production
API keys for tile services are usually restricted to a set of referring domains. localhost is in the allowed list during development and the production hostname is not, so the same code returns 200 locally and 403 deployed.
The console message names the provider and the status; the fix is in the provider's dashboard rather than in the code.
Blocked http tiles, attribution and bounds order
- An https page cannot fetch http tiles. MapLibre and deck.gl are blocked; Leaflet's image tiles are upgraded to https by Chromium.
- A 200 is not a working tile. Keyed providers answer with a placeholder picture; folium's
cartodbpositronnow does for every tile. - A custom
tilesURL in folium requiresattr, or it raises. fit_boundstakes[[south, west], [north, east]]β latitude first.get_radiusin pydeck is metres; setradius_min_pixelsor low zooms show nothing.- Check
gdf.total_boundsagainst the map's view β a national grid is nowhere near lon/lat. - Public tile servers have usage policies; a 429 is enforcement.
- A canvas renderer reading tiles needs CORS on the tile response and
crossOriginon the client. - Give the map an explicit height in any dashboard layout.
Internal links
- Fixing a web map layer that does not appear β the wider diagnosis
- Fixing a folium map that is not showing β the notebook version
- Fixing a tile endpoint that returns 404 or blank tiles β when you serve the tiles
- Fixing CORS errors on a spatial API β the canvas case
- Fixing tiles that are offset or misplaced β when tiles load in the wrong place
- How to build a Streamlit app with an interactive map β the container and the height
- Web map tiles explained β the scheme
- How to make interactive maps with folium β the component
FAQ
Why is my embedded map grey?
Tiles are not arriving, or the ones arriving are placeholders. Check the console for mixed content or CORS, then the network panel for the tile requests, their status codes, and whether every tile is the same size.
Why does my folium map say "API KEY REQUIRED"?
Carto's basemaps, which folium names cartodbpositron and cartodbdark_matter, now need a key. folium 0.20 warns about it when the map is built; the tiles themselves come back as 200 with the message drawn in them.
Why does the map render as an empty box?
The container has no height. Leaflet and deck.gl size themselves from their container, and a dashboard column frequently has none β pass an explicit height.
The basemap shows but my data does not. Why?
The data layer is present and not visible: usually the wrong CRS, empty geometry, or a style that draws nothing. Compare gdf.total_bounds with the map's view.
Why do tiles work locally and 403 in production?
API keys are usually restricted by referring domain. localhost is allowed and the production hostname is not; the fix is in the provider's dashboard.
Why are my http tiles blocked?
An HTTPS page may not fetch HTTP resources. MapLibre and deck.gl requests are blocked, and the network panel marks them (blocked:mixed-content); Leaflet's image tiles are upgraded to HTTPS by Chromium, which only works if the host serves HTTPS. Use the provider's HTTPS URL.
Can I use OpenStreetMap's tiles in my app?
Not at volume β the Foundation's tile servers have a usage policy that excludes application embedding. Use a provider with a plan, or serve your own.