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.

Three contextily maps of 822 US airports: on CARTO Positron the basemap is a grey grid stamped API KEY REQUIRED; on OpenStreetMap with default headers it is a grid of Access blocked tiles; on OpenStreetMap with a descriptive User-Agent it is a real map. Three contextily maps of 822 US airports: on CARTO Positron the basemap is a grey grid stamped API KEY REQUIRED; on OpenStreetMap with default headers it is a grid of Access blocked tiles; on OpenStreetMap with a descriptive User-Agent it is a real map.
Two broken basemaps and a working one.

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.

Which tile providers returned real tiles on 2 October 2026. real tiles or placeholder?tilesrequires_token()CartoDB.Positronβœ•placeholderTrueCartoDB.DarkMatterβœ•placeholderTrueCartoDB.Voyagerβœ•placeholderTrueOpenStreetMap.Mapnikβœ“real, with a User-AgentFalseEsri.WorldGrayCanvasβœ“realFalseOpenTopoMapβœ“realFalsexyzservices 2026.9.1; two tiles per provider compared spatialworkflow.io
Which providers returned real tiles.

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.

The CARTO placeholder tile: a near-white square with API KEY REQUIRED and carto.com/basemaps/apikey written diagonally in grey. The CARTO placeholder tile: a near-white square with API KEY REQUIRED and carto.com/basemaps/apikey written diagonally in grey.
The tile CARTO sends instead of a map.

CARTO's servers started asking for keys

Why a placeholder tile breaks a map without any error. why nothing raisestile requestno keyHTTP 200status looks finevalid PNG2,049 bytessame for everytilethe placeholdermap rendersbasemap is awatermarkCheck the content, not the status spatialworkflow.io
Why the failure is silent.

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

Steps to take when basemap tiles say API KEY REQUIRED. fixing a placeholder basemapcompare two tilesidentical bytes: placeholderxyz.filter(requires_token=False)824 of 937 providersFoliumdrop tiles="CartoDB positron"contextily + OSMheaders= with a User-Agentkeep CARTOapikey= on the providerChecked 2 October 2026 spatialworkflow.io
Fixing a placeholder basemap.
  • 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 a key parameter 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.

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.