How to Add a Basemap to a GeoPandas Map with contextily
Problem statement
Your polygons float on white. Nobody can tell whether they are in Manchester or Marseille:
gdf.plot()
The obvious fix is one line, and it usually fails on the first attempt:
import contextily as cx
ax = gdf.plot()
cx.add_basemap(ax)
Either nothing appears, or the tiles land somewhere else entirely, or you get:
HTTPError: Tile URL resulted in a 404 error
or the map draws a grey blur because the zoom level was inferred from a bounding box in degrees when the tiles expect metres.
Almost all of it comes from one requirement: tile basemaps are served in Web Mercator (EPSG:3857), and your data probably is not.
Quick answer
import geopandas as gpd
import contextily as cx
import matplotlib.pyplot as plt
gdf = gpd.read_file("wards.gpkg").to_crs(3857) # β the whole fix
fig, ax = plt.subplots(figsize=(10, 10))
gdf.plot(ax=ax, alpha=0.6, edgecolor="white", linewidth=0.5)
cx.add_basemap(ax, source=cx.providers.CartoDB.Positron)
ax.set_axis_off()
| Symptom | Cause | Fix |
|---|---|---|
| tiles in the wrong place | data not in EPSG:3857 | gdf.to_crs(3857) |
| grey blur, no detail | zoom inferred from degrees | reproject first |
| nothing drawn | basemap added before the data | plot data, then the basemap |
404 on tiles |
dead provider or bad zoom | pick another cx.providers |
| basemap covers the data | z-order | pass zorder= or add the basemap first |
If reprojecting the data is not acceptable, reproject the tiles instead:
ax = gdf.plot() # data stays in EPSG:27700
cx.add_basemap(ax, crs=gdf.crs, source=cx.providers.CartoDB.Positron)
crs= warps the fetched tiles into your CRS. It is slower and slightly blurry, and it is the right answer when the map's coordinates matter.
Step-by-step solution
1. Reproject to Web Mercator β or tell contextily not to
Every XYZ tile service uses EPSG:3857. add_basemap assumes the axes are already in it unless told otherwise:
print(gdf.crs) # EPSG:27700
gdf3857 = gdf.to_crs(3857)
print(gdf3857.total_bounds) # [-300611. 7043330. -211004. 7130042.]
Web Mercator coordinates are metres from the intersection of the equator and the prime meridian, running to about Β±20,037,508. Numbers in the millions with that magnitude are the sign you are in 3857.
The alternative β crs=gdf.crs β makes contextily fetch tiles and reproject them:
ax = gdf.plot(alpha=0.6)
cx.add_basemap(ax, crs=gdf.crs, source=cx.providers.CartoDB.Positron)
| Approach | Data CRS | Tiles | When |
|---|---|---|---|
to_crs(3857) |
changed | sharp | display only; the default choice |
crs=gdf.crs |
unchanged | warped, softer | axes must be in your CRS |
Reproject the data unless the axis coordinates themselves matter β for a map with a scale bar in metres, or one whose coordinates a reader will use, keep your CRS and warp the tiles.
2. Plot the data first, then the basemap
add_basemap reads the axes' current extent to decide which tiles to fetch. On an empty axis there is no extent:
# β nothing to work from
fig, ax = plt.subplots()
cx.add_basemap(ax) # fetches tiles for the whole world, or fails
# β
the data sets the extent
fig, ax = plt.subplots()
gdf.plot(ax=ax)
cx.add_basemap(ax)
The basemap is drawn on top of what is already there, so make the data layers semi-transparent, or push the basemap behind:
gdf.plot(ax=ax, alpha=0.6, zorder=2)
cx.add_basemap(ax, zorder=1)
zorder is the explicit form and worth using once a map has more than two layers β see how to plot multiple layers on one map.
3. Choose a provider that suits the map
import contextily as cx
for name in ["CartoDB.Positron", "CartoDB.DarkMatter", "OpenStreetMap.Mapnik",
"Esri.WorldImagery", "Esri.WorldTopoMap", "CartoDB.VoyagerNoLabels"]:
provider = cx.providers.query_name(name)
print(f"{name:<28} {provider.get('attribution', '')[:52]}")
| Provider | Character | Use for |
|---|---|---|
CartoDB.Positron |
pale grey, minimal | thematic overlays β the safe default |
CartoDB.PositronNoLabels |
as above, no text | when your own labels would clash |
CartoDB.DarkMatter |
dark | bright overlays, screen display |
OpenStreetMap.Mapnik |
full detail, colourful | reference and context |
Esri.WorldImagery |
satellite | showing real ground cover |
Esri.WorldTopoMap |
terrain and labels | physical geography |
For a choropleth, a busy basemap competes with the data. Positron exists precisely for this, and PositronNoLabels when you add your own labels.
Attribution is a licence condition, not a courtesy. OpenStreetMap tiles require it:
provider = cx.providers.CartoDB.Positron
cx.add_basemap(ax, source=provider)
ax.annotate(provider["attribution"], xy=(0.005, 0.005), xycoords="axes fraction",
fontsize=6, color="#334155", backgroundcolor="#ffffffaa")
add_basemap adds an attribution line by default; attribution=False turns it off, and attribution="β¦" overrides the text. Do not turn it off without a reason.
4. Control the zoom level
cx.add_basemap(ax, source=cx.providers.CartoDB.Positron, zoom=12)
Zoom runs 0 (the whole world in one tile) to about 19 (building level). contextily infers it from the extent, and the inference is usually right β but worth overriding when:
- Text is too small or too large. Tiles are raster images with baked-in labels, so their text does not scale with your figure. A higher zoom gives smaller, denser labels.
- The output is for print. A screen-resolution basemap looks soft at 300 dpi. Raise the zoom by one or two and increase the figure size to match.
- You are fetching too many tiles. Each level quadruples the count. Zoom 16 over a city can be thousands of requests.
import math
def tile_count(bounds_3857, zoom):
"""How many 256-px tiles this extent needs at this zoom."""
world = 20037508.342789244 * 2
span = world / (2 ** zoom)
minx, miny, maxx, maxy = bounds_3857
return (math.ceil((maxx - minx) / span) + 1) * (math.ceil((maxy - miny) / span) + 1)
for z in range(8, 17):
print(f"zoom {z:>2} {tile_count(gdf3857.total_bounds, z):>7,} tiles")
zoom 8 4 tiles
zoom 10 12 tiles
zoom 12 120 tiles
zoom 14 1,798 tiles
zoom 16 28,340 tiles
Twenty-eight thousand tile requests is abusive to a free service and slow for you. contextily caches to disk, so the second run is cheap β but the first is not, and public tile servers rate-limit.
5. Cache, and be a good citizen
import contextily as cx
cx.set_cache_dir("./.tilecache") # persists between runs
Without a cache directory, every run re-fetches. With one, a repeated map is instant and no requests leave your machine.
The OpenStreetMap Foundation's tile usage policy prohibits bulk downloading and heavy automated use. For a batch job producing hundreds of maps, either use a commercial provider with an API key, or fetch the extent once and reuse the image:
# fetch once, reuse for every map in the batch
img, ext = cx.bounds2img(*gdf3857.total_bounds, zoom=12,
source=cx.providers.CartoDB.Positron)
for name, part in gdf3857.groupby("ward_name"):
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(img, extent=ext) # the cached array, not a new request
part.plot(ax=ax, facecolor="none", edgecolor="red", linewidth=2)
ax.set_xlim(ext[0], ext[1]); ax.set_ylim(ext[2], ext[3])
ax.set_axis_off()
fig.savefig(f"out/{name}.png", dpi=150, bbox_inches="tight")
plt.close(fig)
Code examples
Example 1: a reusable basemap function
import geopandas as gpd
import contextily as cx
import matplotlib.pyplot as plt
cx.set_cache_dir("./.tilecache")
PROVIDERS = {
"light": cx.providers.CartoDB.Positron,
"light_plain": cx.providers.CartoDB.PositronNoLabels,
"dark": cx.providers.CartoDB.DarkMatter,
"osm": cx.providers.OpenStreetMap.Mapnik,
"satellite": cx.providers.Esri.WorldImagery,
"terrain": cx.providers.Esri.WorldTopoMap,
}
def map_with_basemap(gdf, *, style="light", zoom=None, pad=0.08, figsize=(10, 10),
reproject=True, **plot_kwargs):
"""Plot a layer over a tile basemap, handling the CRS and the extent."""
source = PROVIDERS.get(style, style)
if reproject:
layer, tile_crs = gdf.to_crs(3857), None
else:
layer, tile_crs = gdf, gdf.crs
fig, ax = plt.subplots(figsize=figsize)
plot_kwargs.setdefault("alpha", 0.65)
plot_kwargs.setdefault("edgecolor", "white")
plot_kwargs.setdefault("linewidth", 0.5)
layer.plot(ax=ax, zorder=2, **plot_kwargs)
# pad the extent so features are not flush against the frame
minx, miny, maxx, maxy = layer.total_bounds
dx, dy = (maxx - minx) * pad, (maxy - miny) * pad
ax.set_xlim(minx - dx, maxx + dx)
ax.set_ylim(miny - dy, maxy + dy)
cx.add_basemap(ax, source=source, zoom=zoom or "auto", crs=tile_crs, zorder=1)
ax.set_axis_off()
return fig, ax
fig, ax = map_with_basemap(
gpd.read_file("wards.gpkg"),
style="light", column="income", scheme="quantiles", k=5,
cmap="YlOrRd", legend=True,
)
fig.savefig("income.png", dpi=200, bbox_inches="tight")
Two details repay themselves. Setting the limits after plotting and before add_basemap means the basemap covers the padded extent rather than the tight data bounds, so there is no white border. And zorder is explicit on both layers, so adding a third later cannot silently reorder them.
Example 2: keeping your own CRS
When the axes must be in a projected national grid β for a scale bar, or coordinates a reader will use:
import geopandas as gpd, contextily as cx, matplotlib.pyplot as plt
gdf = gpd.read_file("sites.gpkg").to_crs(27700) # British National Grid
fig, ax = plt.subplots(figsize=(10, 10))
gdf.plot(ax=ax, color="#ef4444", markersize=40, zorder=3)
cx.add_basemap(ax, crs=gdf.crs, # β warp the tiles, not the data
source=cx.providers.CartoDB.Positron, zoom=13, zorder=1)
# now a scale bar is meaningful, because the axes really are in metres
from matplotlib.patches import Rectangle
x0, x1 = ax.get_xlim(); y0, y1 = ax.get_ylim()
bar_m = 1000
bx, by = x0 + (x1 - x0) * 0.05, y0 + (y1 - y0) * 0.05
ax.add_patch(Rectangle((bx, by), bar_m, (y1 - y0) * 0.006,
facecolor="black", zorder=4))
ax.text(bx + bar_m / 2, by + (y1 - y0) * 0.012, f"{bar_m//1000} km",
ha="center", fontsize=9, zorder=4)
ax.set_axis_off()
This is the case that justifies the slower path. A scale bar drawn on Web Mercator axes is wrong by 1/cos(latitude) β about 61% at British latitudes β because Mercator's scale varies with latitude. Keeping the axes in EPSG:27700 makes one metre on the axis one metre on the ground, so the bar is honest. The distortion argument is in choosing a map projection for display.
Example 3: diagnosing a basemap that will not appear
import contextily as cx
import geopandas as gpd
import matplotlib.pyplot as plt
def diagnose_basemap(gdf, source=cx.providers.CartoDB.Positron, zoom=None):
print(f"data CRS {gdf.crs}")
print(f"data bounds {[round(v, 1) for v in gdf.total_bounds]}")
if gdf.crs is None:
return "no CRS β set one before adding a basemap"
if gdf.crs.to_epsg() != 3857:
print(f" β not Web Mercator; reproject or pass crs={gdf.crs}")
g = gdf.to_crs(3857)
minx, miny, maxx, maxy = g.total_bounds
LIMIT = 20037508.342789244
if not (-LIMIT <= minx <= LIMIT and -LIMIT <= maxx <= LIMIT):
return f"bounds outside the Web Mercator domain: {g.total_bounds}"
if abs(gdf.to_crs(4326).total_bounds[1]) > 85 or abs(gdf.to_crs(4326).total_bounds[3]) > 85:
return "data reaches beyond Β±85Β° latitude β Web Mercator is undefined there"
try:
img, ext = cx.bounds2img(minx, miny, maxx, maxy,
zoom=zoom or "auto", source=source)
print(f" β fetched {img.shape[1]}Γ{img.shape[0]} px covering {[round(e) for e in ext]}")
except Exception as exc:
return f"tile fetch failed: {type(exc).__name__}: {exc}"
fig, ax = plt.subplots(figsize=(8, 8))
ax.imshow(img, extent=ext)
g.boundary.plot(ax=ax, color="red", linewidth=1.5)
ax.set_title("basemap and data, drawn from the same extent")
return "ok"
print(diagnose_basemap(gpd.read_file("wards.gpkg")))
data CRS EPSG:27700
data bounds [351204.1, 381009.4, 407881.2, 445902.3]
β not Web Mercator; reproject or pass crs=EPSG:27700
β fetched 1024Γ1024 px covering [-313086, -195312, 7043228, 7161087]
ok
The checks are ordered by how badly each one breaks things: no CRS makes every later step meaningless, out-of-domain bounds make the tile request nonsensical, and only then is it worth actually fetching. Calling bounds2img directly separates "the tiles cannot be fetched" from "the tiles were fetched and drawn in the wrong place", which are different problems with different fixes.
Explanation
A tile basemap is a pre-rendered raster pyramid, and every constraint in this article follows from that.
The tiles were drawn in advance, in Web Mercator, at fixed zoom levels, in 256-pixel squares whose positions are defined by a scheme every provider shares: at zoom z the world is a 2^z Γ 2^z grid, and tile (x, y, z) covers a known rectangle of Web Mercator space. To place a tile on your axes, contextily needs those axes to be in the same coordinate space. If they are not β if they are in metres from a national grid origin, or in degrees β the tile's coordinates mean nothing there, and it lands wherever those numbers happen to fall.
This is why EPSG:3857 is not negotiable. It is not that contextily prefers it; it is that the tiles were rendered in it and cannot be re-rendered. The crs= argument does the only other thing possible: fetch the tiles, warp the resulting image into your CRS, and draw the warped version. That works, costs a resampling step, and softens the labels slightly β the same trade as any raster reprojection.
Zoom level is a resolution choice, not a scale choice. Because tiles are raster images with labels baked in, their text size is fixed in pixels. Rendering a zoom-10 tile across a large figure enlarges its labels along with everything else, which is why an under-zoomed basemap looks like a blurry poster. Going the other way, an over-zoomed basemap has tiny labels and costs 4Γ the requests per level. contextily's automatic choice targets a sensible pixel density for the axes; override it when the output medium differs β print at 300 dpi wants one or two levels more than a screen.
The extent has to exist before the tiles are chosen, which is why order matters. add_basemap reads ax.get_xlim() and ax.get_ylim() to decide which tiles to fetch, so calling it on an empty axis asks for the default extent β the whole world, or nothing. Setting the limits explicitly before adding the basemap, as in Example 1, also solves the related annoyance where the basemap stops exactly at the data's bounding box and leaves a white margin.
Finally, there is an ethical constraint that is easy to overlook because the code does not enforce it. Public tile services β OpenStreetMap most of all β are volunteer-funded, and their usage policies prohibit bulk downloading. A batch job that renders 500 maps at zoom 15 can issue hundreds of thousands of requests. Setting a cache directory, fetching one image and reusing it across a batch, or paying for a commercial provider are the three honest answers, and the first two are one line each.
Edge cases or notes
cx.set_cache_dir()should be one of the first lines in any script that draws maps. Without it, every run re-fetches.- Plot data before
add_basemap, or the axes have no extent to fetch tiles for. attribution=Falseremoves a licence-required credit. Do not use it on published output without an alternative credit.zoom="auto"is the default; pass an integer for print, and check the tile count first.crs=warps the tiles, which is slower and slightly blurry but keeps your axes in your CRS.- Beyond Β±85Β° latitude Web Mercator is undefined. Clip before reprojecting.
cx.providers.query_name("CartoDB.Positron")looks a provider up by string, useful for config-driven scripts.- Some providers need an API key, supplied through the provider dict:
cx.providers.Stadia.AlidadeSmooth(api_key="β¦"). - A 404 usually means a dead provider or an unsupported zoom, not a bug in your code. Try another provider.
cx.bounds2imgreturns an array and an extent, which is the right primitive for batch work and for caching by hand.
Internal links
- How to plot maps in Python with GeoPandas and Matplotlib β the plotting layer underneath
- Choosing a map projection for display β why tiles force Web Mercator
- How to plot multiple layers on one map in GeoPandas β z-order and layer discipline
- How to add legends, labels and a scale bar to a GeoPandas map β finishing the map
- How to batch-generate a map image for every region β reusing one basemap fetch
- How to reproject spatial data in Python (GeoPandas) β the
to_crscall this depends on - How to save a map as an image in Python with Matplotlib β export settings for print
- How to create interactive maps in Python with Folium β when a static basemap is not enough
FAQ
Why does my basemap appear in the wrong place?
The data is not in EPSG:3857. Either call gdf.to_crs(3857) before plotting, or pass crs=gdf.crs to add_basemap so the tiles are warped into your CRS instead.
Should I reproject the data or the tiles?
Reproject the data for display-only maps β the tiles stay sharp. Warp the tiles when the axes themselves must be in your CRS, for instance so a scale bar in metres is correct.
Why is the basemap blurry?
The zoom level is too low for the figure size, so tiles are being stretched. Pass a higher zoom=, and raise it one or two more for print output at 300 dpi.
Which provider should I use for a choropleth?
CartoDB.Positron, or PositronNoLabels if you add your own labels. A detailed basemap competes with the data for attention.
How do I avoid hammering the tile server?
Call cx.set_cache_dir(), keep the zoom no higher than needed, and for batch work fetch one image with cx.bounds2img and reuse it. Public tile services prohibit bulk downloading.
Do I have to show the attribution?
Yes for OpenStreetMap and most free providers β it is a licence condition. add_basemap adds it by default; leave it on unless you credit the source another way.
My basemap hides my data.
The basemap is drawn after and therefore on top. Pass zorder=1 to the basemap and a higher zorder to the data layers, and give the data some transparency.