How to Make a Map With Cartopy in Python

On this page

Problem statement

Cartopy is the standard library for publication-quality static maps in Python. It adds map projections, coastlines and gridlines to matplotlib, and it can centre a world map on the Pacific, draw a regional map in a conformal projection or show the globe from space. It also has one rule that causes most of the questions about it: the projection of the axes and the coordinate system of the data are separate, and plotting longitude and latitude without saying so puts everything in the wrong place.

This page maps every magnitude 4.5 and larger earthquake of 2024 with Cartopy: a Pacific-centred world map in the Robinson projection, a regional map of Japan in a Lambert conformal projection, Natural Earth borders and labelled gridlines, and a check of what happens without transform.

Quick answer

Create axes with a projection, add features, and plot the data with transform=ccrs.PlateCarree() because the data is longitude and latitude:

import cartopy.crs as ccrs
import cartopy.feature as cfeature
fig = plt.figure(figsize=(10, 5.4))
ax = plt.axes(projection=ccrs.Robinson(central_longitude=180))
ax.set_global()
ax.add_feature(cfeature.LAND, facecolor="#e2e8f0"); ax.add_feature(cfeature.OCEAN, facecolor="#e8f4fd"); ax.coastlines(linewidth=0.4)
sc = ax.scatter(Q.longitude, Q.latitude, s=(Q.mag - 4) ** 2 * 3, c=Q.depth, cmap="plasma_r", vmin=0, vmax=300,
                transform=ccrs.PlateCarree(), linewidths=0, alpha=0.7)
fig.colorbar(sc, ax=ax, shrink=0.6, label="depth (km)")
fig.savefig("data/cartopy_world.png", dpi=150, bbox_inches="tight"); plt.close(fig)
print(f"projection: {ax.projection.to_proj4()}")
print(f"PNG {Image.open('data/cartopy_world.png').size}")
projection: +proj=robin +lon_0=180 +x_0=0 +y_0=0 +R=6378137 +units=m +no_defs +type=crs
PNG (1119, 501)

The axes use the Robinson projection centred on 180°, in metres on a sphere of radius 6,378,137 m. The saved PNG is 1119 × 501 pixels.

Cartopy world map in the Robinson projection centred on the Pacific, with 2024 earthquakes along the Ring of Fire coloured from yellow for shallow to dark purple for 300 km deep, and a depth colour bar. Cartopy world map in the Robinson projection centred on the Pacific, with 2024 earthquakes along the Ring of Fire coloured from yellow for shallow to dark purple for 300 km deep, and a depth colour bar.
The quick-answer map.

Projection and transform, a regional map, projections compared

1. projection and transform

projection= sets how the map is drawn; transform= says what the data's coordinates are. Without transform, Cartopy reads the numbers as metres in the map projection:

lon, lat = 145.4596, 17.6625                                                # the first earthquake of 2024
x, y = ax.projection.transform_point(lon, lat, ccrs.PlateCarree())
print(f"with transform:    ({x / 1000:,.0f} km, {y / 1000:,.0f} km) in Robinson coordinates")
print(f"without transform: ({lon:.0f} m, {lat:.0f} m) from the map centre at 180°, 0°")
far = np.hypot(Q.longitude, Q.latitude).max()
print(f"without transform every earthquake lands within {far:.0f} m of the centre")
with transform:    (-3,218 km, 1,889 km) in Robinson coordinates
without transform: (145 m, 18 m) from the map centre at 180°, 0°
without transform every earthquake lands within 191 m of the centre

With transform, the first earthquake of 2024, north of Saipan, lands 3,218 km west and 1,889 km north of the map centre. Without it, Cartopy reads its longitude and latitude as 145 m and 18 m, and every one of the 6,397 earthquakes falls within 191 m of the centre: a single dot in the middle of the Pacific.

2. A regional map

For a region, choose a projection suited to it and set the extent in longitude and latitude. Japan in a Lambert conformal conic projection:

proj = ccrs.LambertConformal(central_longitude=138, central_latitude=36, standard_parallels=(30, 42))
fig = plt.figure(figsize=(7, 7)); ax2 = plt.axes(projection=proj)
ax2.set_extent([125, 150, 24, 46], crs=ccrs.PlateCarree())
ax2.add_feature(cfeature.LAND.with_scale("50m"), facecolor="#e2e8f0"); ax2.coastlines("50m", linewidth=0.4)
ax2.add_feature(cfeature.BORDERS.with_scale("50m"), linewidth=0.4, linestyle=":")
J = Q[Q.longitude.between(125, 150) & Q.latitude.between(24, 46)]
ax2.scatter(J.longitude, J.latitude, s=(J.mag - 4) ** 2 * 6, c=J.depth, cmap="plasma_r", vmin=0, vmax=300, transform=ccrs.PlateCarree(), linewidths=0)
gl = ax2.gridlines(draw_labels=True, linewidth=0.3, color="grey", x_inline=False, y_inline=False)
gl.top_labels = gl.right_labels = False
fig.savefig("data/cartopy_japan.png", dpi=150, bbox_inches="tight"); plt.close(fig)
print(f"earthquakes in the box: {len(J)}; largest M{J.mag.max()} {J.loc[J.mag.idxmax(), 'place']} on {J.loc[J.mag.idxmax(), 'time'][:10]}")
earthquakes in the box: 511; largest M7.5 2024 Noto Peninsula, Japan Earthquake on 2024-01-01

The box around Japan holds 511 earthquakes. The largest is the magnitude 7.5 Noto Peninsula earthquake of 1 January 2024.

Cartopy map of Japan in a Lambert conformal conic projection with curved gridlines labelled from 24° to 45° N and 130° to 150° E, and 511 earthquakes, including a cluster on the Noto Peninsula. Cartopy map of Japan in a Lambert conformal conic projection with curved gridlines labelled from 24° to 45° N and 130° to 150° E, and 511 earthquakes, including a cluster on the Noto Peninsula.
A regional map in a conformal projection.

3. Projections compared

Each projection has its own coordinates. Check the units and the width of the world in four common ones:

for name, p in (("PlateCarree", ccrs.PlateCarree()), ("Robinson", ccrs.Robinson()), ("Mollweide", ccrs.Mollweide()),
                ("Orthographic", ccrs.Orthographic(150, 10))):
    w = p.x_limits[1] - p.x_limits[0]
    unit = "degrees" if isinstance(p, ccrs.PlateCarree) else "metres"
    print(f"{name:>12}: x from {p.x_limits[0]:,.0f} to {p.x_limits[1]:,.0f} {unit}")
 PlateCarree: x from -180 to 180 degrees
    Robinson: x from -17,005,833 to 17,005,833 metres
   Mollweide: x from -18,040,096 to 18,040,096 metres
Orthographic: x from -6,378,073 to 6,378,073 metres

PlateCarree works in degrees, while the others work in metres. Robinson spans 34,012 km across and Mollweide 36,080 km. The orthographic globe spans twice the Earth's radius, 12,756 km, because it shows only one hemisphere.

The globe from space

The data used on this page

Every earthquake of magnitude 4.5 or more worldwide in 2024 from the USGS FDSN event service, as CSV. Cartopy downloads the Natural Earth coastlines, land and borders on first use and caches them.

from pathlib import Path

import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests
from PIL import Image

Path("data").mkdir(exist_ok=True)
p = Path("data/quakes_m45_2024.csv")
if not p.exists():
    r = requests.get("https://earthquake.usgs.gov/fdsnws/event/1/query", timeout=600,
                     params=dict(format="csv", starttime="2024-01-01", endtime="2025-01-01", minmagnitude=4.5, orderby="time-asc"))
    r.raise_for_status()
    p.write_bytes(r.content)
Q = pd.read_csv(p)
Q = Q[Q["type"] == "earthquake"].reset_index(drop=True)
print(f"{len(Q):,} earthquakes of M4.5+ in 2024")
6,397 earthquakes of M4.5+ in 2024

The catalogue holds 6,397 earthquakes of magnitude 4.5 or more for 2024.

Example 1: the globe from space

An orthographic projection shows one hemisphere as a globe. Points on the far side are dropped:

ortho = ccrs.Orthographic(central_longitude=150, central_latitude=10)
fig = plt.figure(figsize=(6, 6)); ax3 = plt.axes(projection=ortho); ax3.set_global()
ax3.add_feature(cfeature.LAND, facecolor="#e2e8f0"); ax3.add_feature(cfeature.OCEAN, facecolor="#e8f4fd"); ax3.coastlines(linewidth=0.4)
ax3.gridlines(linewidth=0.3, color="grey")
ax3.scatter(Q.longitude, Q.latitude, s=4, color="#ef4444", transform=ccrs.PlateCarree(), linewidths=0)
fig.savefig("data/cartopy_globe.png", dpi=150, bbox_inches="tight"); plt.close(fig)
xy = ortho.transform_points(ccrs.PlateCarree(), Q.longitude.values, Q.latitude.values)
visible = np.isfinite(xy[:, 0])
print(f"visible from above 150°E, 10°N: {visible.sum():,} of {len(Q):,} earthquakes ({visible.mean():.0%})")
visible from above 150°E, 10°N: 4,747 of 6,397 earthquakes (74%)

Seen from above 150° E, 10° N, 4,747 of the 6,397 earthquakes are on the visible side, 74%, because most of 2024's large earthquakes were around the western Pacific.

Orthographic globe centred on 150° E, 10° N, showing earthquakes as red dots from Japan through Indonesia to Tonga and New Zealand. Orthographic globe centred on 150° E, 10° N, showing earthquakes as red dots from Japan through Indonesia to Tonga and New Zealand.
The globe from space.

Cartopy's coordinate reference objects

The two coordinate systems in every Cartopy plot: projection and transform. projection is the map, transform is the dataprojection=✓set once, on the axes✓Robinson, Lambert, Orthographic...✓units usually metres✓decides how the map lookstransform=✓set on every plotting call✓PlateCarree() for lon/lat data✓left out: data read as metres✓6,397 points within 191 mCartopy reprojects from transform to projection spatialworkflow.io
Projection and transform.

Cartopy's central idea is the coordinate reference system object: ccrs.PlateCarree() for plain longitude and latitude, and classes such as Robinson, Mollweide, LambertConformal and Orthographic for projections. A GeoAxes is created in one of them, and every plotting call takes a transform naming the coordinates of its data, which Cartopy reprojects into the axes' projection. When transform is left out, the data is assumed to be already in the axes' coordinates. On a projected map those are metres, so longitude and latitude values of a few hundred become a few hundred metres from the centre, and every point collapses into a dot.

The features come from Natural Earth at three scales, 110m, 50m and 10m, downloaded on first use: the coarse scale suits world maps and the finer ones regional maps. Gridline labels work on most projections with draw_labels=True. Because the output is matplotlib, everything else, from colour bars to annotations and savefig at any resolution, works as usual.

GeoPandas axes, the dateline and set_extent

Cartopy problems and fixes. Cartopy trapsSymptomFixall points in one dottransform=ccrs.PlateCarree()set_xlim in degrees: tiny boxset_extent([...],crs=PlateCarree())lines cross the map at 180°transform=ccrs.Geodetic()coarse regional coastlinecoastlines("50m") or "10m"Cartopy with matplotlib spatialworkflow.io
Cartopy traps.
  • GeoPandas on Cartopy axes: gdf.plot(ax=ax, transform=ccrs.PlateCarree()) for data in EPSG:4326, or reproject the GeoDataFrame to the axes' CRS with gdf.to_crs(ax.projection.proj4_init).
  • Lines across the dateline: with transform=ccrs.Geodetic() lines follow great circles and cross 180° correctly; PlateCarree draws straight lines in longitude and latitude.
  • Offline use: set cartopy.config["data_dir"] to a directory with the Natural Earth files pre-downloaded.
  • set_extent: always pass crs=ccrs.PlateCarree() when the extent is in degrees.

FAQ

How do I make a map with Cartopy?

Create axes with plt.axes(projection=ccrs.Robinson()), add ax.coastlines(), and plot data with transform=ccrs.PlateCarree().

What is the difference between projection and transform in Cartopy?

projection is how the map is drawn; transform is the coordinate system of the data being plotted. Longitude and latitude data always takes transform=ccrs.PlateCarree().

How do I centre a Cartopy map on the Pacific?

Use central_longitude=180 in the projection, for example ccrs.Robinson(central_longitude=180).

How do I zoom a Cartopy map to a region?

Call ax.set_extent([lon_min, lon_max, lat_min, lat_max], crs=ccrs.PlateCarree()).