Fixing Basemap Tiles That Say "API KEY REQUIRED" in Python
On this page
Problem statement
Maps that used CARTO's free basemaps, Positron, Dark Matter and Voyager, now show a grey grid stamped "API KEY REQUIRED" and "carto.com/basemaps/apikey" instead of streets and coastlines. The data still draws on top, so the map works but is unusable. It affects every Python library that loads CARTO raster tiles by name: Folium with tiles="CartoDB positron", contextily with ctx.providers.CartoDB.Positron, GeoDataFrame.explore() with a CARTO provider, and anything else built on xyzservices.
This page confirms the change by downloading tiles directly, shows how to detect a placeholder tile from Python, checks what xyzservices reports, and switches Folium and contextily maps to providers that work without a key. All tiles were requested on 2 October 2026.
Quick answer
Detect the placeholder by requesting two different tiles: real tiles differ, a placeholder is the same image every time. Then switch to a provider that needs no key:
def is_placeholder(provider):
a, b = (requests.get(provider.build_url(x=x, y=y, z=z), headers=UA, timeout=30).content for z, x, y in ((3, 2, 3), (5, 8, 12)))
return a == b
for name in ("CartoDB.Positron", "CartoDB.DarkMatter", "CartoDB.Voyager", "OpenStreetMap.Mapnik", "Esri.WorldGrayCanvas", "OpenTopoMap"):
print(f"{name:>21}: placeholder {is_placeholder(xyz.query_name(name))}")
CartoDB.Positron: placeholder True
CartoDB.DarkMatter: placeholder True
CartoDB.Voyager: placeholder True
OpenStreetMap.Mapnik: placeholder False
Esri.WorldGrayCanvas: placeholder False
OpenTopoMap: placeholder False
All three CARTO basemaps return byte-for-byte the same image for a tile at zoom 3 and a tile of a different place at zoom 5: the placeholder. OpenStreetMap, Esri's World Gray Canvas and OpenTopoMap return different images, real tiles.
What xyzservices says, then Folium and contextily
1. What xyzservices says
xyzservices, the provider catalogue behind Folium, contextily and explore(), now lists the CARTO tiles as needing a key:
print(f"xyzservices {xyzservices.__version__}")
for name in ("CartoDB.Positron", "OpenStreetMap.Mapnik", "Esri.WorldGrayCanvas"):
p = xyz.query_name(name)
print(f"{name:>21}: requires_token() {p.requires_token()}; url {p.url}")
free = xyz.filter(requires_token=False).flatten()
print(f"providers that need no key: {len(free)} of {len(xyz.flatten())}")
xyzservices 2026.9.1
CartoDB.Positron: requires_token() True; url https://{s}.basemaps.cartocdn.com/{variant}/{z}/{x}/{y}{r}.png?key={apikey}
OpenStreetMap.Mapnik: requires_token() False; url https://tile.openstreetmap.org/{z}/{x}/{y}.png
Esri.WorldGrayCanvas: requires_token() False; url https://server.arcgisonline.com/ArcGIS/rest/services/{variant}/MapServer/tile/{z}/{y}/{x}
providers that need no key: 824 of 937
xyzservices 2026.9.1 reports requires_token() as true for CARTO, whose URL now ends in ?key={apikey}, and false for OpenStreetMap and Esri. 824 of its 937 providers need no key.
2. Folium
Folium resolves tile names through xyzservices. Replace the CARTO name with the default OpenStreetMap tiles, or pass another provider that needs no key with its attribution:
m = folium.Map(location=[39, -98], zoom_start=4) # OpenStreetMap by default
gray = xyz.Esri.WorldGrayCanvas
m2 = folium.Map(location=[39, -98], zoom_start=4, tiles=gray.build_url(), attr=gray.html_attribution)
for name, mm in (("default", m), ("Esri.WorldGrayCanvas", m2)):
html = mm.get_root().render()
print(f"{name:>21}: CARTO tiles in the page {'cartocdn' in html}")
default: CARTO tiles in the page False
Esri.WorldGrayCanvas: CARTO tiles in the page False
Neither page requests CARTO tiles: the default is OpenStreetMap, and the Esri provider brings its own URL and attribution.
3. contextily
ctx.add_basemap takes the provider as source and request headers as headers. contextily's default User-Agent is a random contextily-<hex> string, which OpenStreetMap's servers block, so switching away from CARTO needs either a descriptive User-Agent for OpenStreetMap or a provider that accepts the default:
g = gpd.GeoDataFrame(LM, geometry=gpd.points_from_xy(LM.longitude_deg, LM.latitude_deg), crs=4326).to_crs(3857)
cases = (("carto", ctx.providers.CartoDB.Positron, None), ("osm_default", ctx.providers.OpenStreetMap.Mapnik, None),
("osm_ua", ctx.providers.OpenStreetMap.Mapnik, UA), ("esri", ctx.providers.Esri.WorldGrayCanvas, None))
for name, src, headers in cases:
fig, ax = plt.subplots(figsize=(8, 5))
g.plot(ax=ax, markersize=4, color="#1a3a6b")
ctx.add_basemap(ax, source=src, zoom=4, headers=headers)
img = ax.images[0].get_array()
fig.savefig(f"data/ctx_{name}.png", dpi=100, bbox_inches="tight"); plt.close(fig)
n = len(np.unique(img.reshape(-1, img.shape[-1]), axis=0))
print(f"{name:>11}: a real basemap (over 200 distinct colours): {n > 200}")
carto: a real basemap (over 200 distinct colours): False
osm_default: a real basemap (over 200 distinct colours): False
osm_ua: a real basemap (over 200 distinct colours): True
esri: a real basemap (over 200 distinct colours): True
Only two of the four basemaps are real maps. CARTO returns its placeholder. OpenStreetMap with contextily's default User-Agent returns its "Access blocked" tile, which draws just as quietly. OpenStreetMap with a descriptive User-Agent, and Esri's World Gray Canvas with the default one, both draw a real basemap under the airports.
The placeholder tile itself
The data used on this page
Map tiles from CARTO, OpenStreetMap, Esri and OpenTopoMap, requested directly with a descriptive User-Agent as the OpenStreetMap tile usage policy asks, and the large and medium US airports from OurAirports as the data layer.
from pathlib import Path
import contextily as ctx
import folium
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
import xyzservices
import xyzservices.providers as xyz
Path("data").mkdir(exist_ok=True)
UA = {"User-Agent": "spatialworkflow-guide/1.0 (+https://www.spatialworkflow.io)"}
p = Path("data/airports.csv")
if not p.exists():
r = requests.get("https://davidmegginson.github.io/ourairports-data/airports.csv", timeout=300)
r.raise_for_status()
p.write_bytes(r.content)
A = pd.read_csv(p, keep_default_na=False, na_values=[""])
LM = A[(A.iso_country == "US") & A.type.isin(["large_airport", "medium_airport"]) & A.longitude_deg.between(-130, -60)].reset_index(drop=True)
print(f"{len(LM)} airports in the conterminous US")
822 airports in the conterminous US
The data layer is 822 large and medium airports in the conterminous US.
Example 1: the placeholder tile itself
Look at what the CARTO server returns:
r = requests.get(xyz.CartoDB.Positron.build_url(x=1, y=1, z=2), headers=UA, timeout=30)
Path("data/carto_tile.png").write_bytes(r.content)
from PIL import Image
im = np.asarray(Image.open("data/carto_tile.png").convert("RGB"))
print(f"status {r.status_code}; {len(r.content):,} bytes; {im.shape[1]} x {im.shape[0]} px; {(im.min(axis=2) > 240).mean():.0%} of pixels near white")
status 200; 2,049 bytes; 256 x 256 px; 97% of pixels near white
The server answers with HTTP 200 and a 256 Γ 256 pixel PNG of 2,049 bytes, 97% of it near white: the watermark.
CARTO's servers started asking for keys
Web maps are assembled from tiles, 256-pixel images requested by zoom, column and row from a tile server. For years CARTO's basemap servers answered such requests without any key, and Positron became the default light basemap in countless tutorials, Folium examples and contextily scripts. CARTO now requires an API key for these raster tiles. Its servers still answer with HTTP 200, but every request without a key receives the same placeholder image pointing to carto.com/basemaps/apikey. Because the response is a valid image, nothing in Python raises an error: the map renders, only the basemap is wrong.
That is also why the check has to look at the content. A status code test passes; comparing two tiles from different places does not, because a real tile server never returns the same bytes for an ocean and a city. xyzservices has caught up, with requires_token() now true for the CARTO providers, so filtering on it is the quickest way to choose a replacement. The OpenStreetMap Foundation's own tiles suit light use with attribution; heavy or commercial use calls for a paid provider or self-hosted tiles. CARTO's vector basemap styles, used by Plotly's carto-positron and by pydeck and lonboard, were still served without a key on the same day.
Saved maps, keys and OpenStreetMap terms
- Saved HTML maps: maps saved before the change break too, because the browser requests the tiles every time the page opens.
- Using CARTO with a key: register at CARTO and pass the key through the provider,
xyz.CartoDB.Positron(apikey="..."); the URL template carries akeyparameter for it. - OpenStreetMap from scripts: set a descriptive User-Agent, as contextily's
headers=allows. Generic or random agents get an "Access blocked" tile, which a plot shows without any error. - Offline or heavy use: build your own tiles, for example a PMTiles archive, and serve them yourself.
Internal links
- How to add a basemap with GeoPandas and contextily β basemaps for static maps
- Fixing a map app that shows blank or grey tiles β other tile failures
- How to save a Folium map as a PNG image in Python β where a placeholder passes unnoticed
FAQ
Why do my map tiles say "API KEY REQUIRED"?
The map uses CARTO's raster basemaps (Positron, Dark Matter, Voyager), which now return a placeholder tile unless a CARTO API key is supplied.
How do I fix CartoDB positron in Folium?
Remove tiles="CartoDB positron" to use OpenStreetMap, choose another provider that needs no key, or add a CARTO API key.
How do I fix contextily CartoDB basemaps?
Pass another source to ctx.add_basemap: ctx.providers.Esri.WorldGrayCanvas works as is, while ctx.providers.OpenStreetMap.Mapnik also needs headers={"User-Agent": "your-app/1.0 (contact)"}. Or use a CARTO provider with your key.
Which basemaps work without an API key?
Filter xyzservices with xyz.filter(requires_token=False); OpenStreetMap, Esri's World Gray Canvas and OpenTopoMap all returned real tiles when this page was run.