Fixing Cartopy Points Plotted in the Wrong Place

On this page

Problem statement

Points plotted on a Cartopy map end up as a single dot in the middle, in the ocean off Africa, or shifted hundreds of kilometres from where they belong, while the coastlines look perfect. The coastlines are right because Cartopy knows their coordinate system; the points are wrong because it was not told theirs. Every plotting call on a GeoAxes takes a transform naming the coordinates of the data, and leaving it out, or giving the wrong one, is behind nearly every misplaced point.

This page reproduces the four common versions on real data, the 2024 earthquakes around Japan from the USGS catalogue: no transform on a projected map, projected data plotted as if it were longitude and latitude, axis limits set in degrees, and GeoPandas data on Cartopy axes. It measures how far each puts the points from where they should be.

Quick answer

Longitude and latitude data takes transform=ccrs.PlateCarree(), whatever the projection of the map:

proj = ccrs.LambertConformal(central_longitude=138, central_latitude=36)
right = proj.transform_points(ccrs.PlateCarree(), J.longitude.values, J.latitude.values)[:, :2]
wrong = np.c_[J.longitude.values, J.latitude.values]                     # what the axes get without transform
print(f"{len(J)} earthquakes; with transform they span x {right[:, 0].min() / 1000:,.0f} to {right[:, 0].max() / 1000:,.0f} km")
print(f"without transform they span x {wrong[:, 0].min():.0f} to {wrong[:, 0].max():.0f} m: all within {np.hypot(*wrong.T).max():.0f} m of the map centre")
511 earthquakes; with transform they span x -1,332 to 940 km
without transform they span x 125 to 150 m: all within 157 m of the map centre

With the transform, the earthquakes spread 2,272 km from west to east across the Lambert map. Without it, their longitudes of 125 to 150 become 125 to 150 m, and all 511 sit within 157 m of the map centre: one dot.

Two Cartopy maps of Japan in a Lambert projection: without transform a single red dot sits in central Honshu; with transform PlateCarree 511 earthquakes line the trenches east of Japan and the Ryukyu arc. Two Cartopy maps of Japan in a Lambert projection: without transform a single red dot sits in central Honshu; with transform PlateCarree 511 earthquakes line the trenches east of Japan and the Ryukyu arc.
The same scatter call with and without transform.

Projected data needs its CRS, degree limits, GeoPandas axes

1. Projected data needs its own CRS

Data already in a projected CRS, such as UTM eastings and northings, needs that CRS as the transform. Convert the earthquakes to UTM zone 54N and compare the right and wrong transforms:

from pyproj import Transformer
e, n = Transformer.from_crs(4326, 32654, always_xy=True).transform(J.longitude.values, J.latitude.values)
utm = ccrs.UTM(54)
ok = proj.transform_points(utm, e, n)[:, :2]
print(f"transform=ccrs.UTM(54): largest offset from the true position {np.hypot(*(ok - right).T).max():.3f} m")
bad = proj.transform_points(ccrs.PlateCarree(), e, n)[:, :2]
print(f"transform=ccrs.PlateCarree() on eastings: finite positions {np.isfinite(bad).all(axis=1).sum()} of {len(J)}")
print(f"first easting, northing: ({e[0]:,.0f}, {n[0]:,.0f}), read as degrees far outside Β±180, Β±90")
transform=ccrs.UTM(54): largest offset from the true position 0.000 m
transform=ccrs.PlateCarree() on eastings: finite positions 0 of 511
first easting, northing: (1,156,083, 4,953,212), read as degrees far outside Β±180, Β±90

With transform=ccrs.UTM(54), the UTM points land exactly where the longitude and latitude versions do. With PlateCarree, eastings of over a million read as degrees, and none of the 511 points gets a finite position on the map: they vanish without an error.

2. Limits in degrees

ax.set_extent takes longitude and latitude (and says so explicitly with crs=ccrs.PlateCarree()), but ax.set_xlim and ax.set_ylim work in the projection's own units. Degrees passed to them make a box a few metres wide:

fig = plt.figure(); ax = plt.axes(projection=proj)
ax.set_xlim(125, 150); ax.set_ylim(24, 46)
w = ax.get_xlim()[1] - ax.get_xlim()[0]
ax.set_extent([125, 150, 24, 46], crs=ccrs.PlateCarree())
w2 = ax.get_xlim()[1] - ax.get_xlim()[0]
plt.close(fig)
print(f"set_xlim(125, 150): {w:,.0f} m wide; set_extent with crs=PlateCarree(): {w2 / 1000:,.0f} km wide")
set_xlim(125, 150): 25 m wide; set_extent with crs=PlateCarree(): 2,606 km wide

set_xlim(125, 150) makes a map 25 m wide, while set_extent with degrees gives the intended 2,606 km.

Where points land in four Cartopy mistakes, measured on 511 earthquakes. four ways to misplace pointsresultfixno transformβœ•all within 157 mtransform=PlateCarree()UTM read as lon/latβœ•0 of 511 drawntransform=ccrs.UTM(54)set_xlim in degreesβœ•map 25 m wideset_extent(..., crs=...)GeoPandas to_crsβœ“0.000 m offsetto_crs(proj4_init)Lambert conformal map of Japan, USGS 2024 spatialworkflow.io
Four ways to misplace points.

3. GeoPandas on Cartopy axes

GeoDataFrame.plot(ax=ax) draws raw coordinates too. Either pass transform= or reproject the GeoDataFrame to the axes' CRS first:

g = gpd.GeoDataFrame(J, geometry=gpd.points_from_xy(J.longitude, J.latitude), crs=4326)
g_proj = g.to_crs(proj.proj4_init)
print(f"to_crs(proj.proj4_init): largest offset from Cartopy's own transform {np.hypot(g_proj.geometry.x - right[:, 0], g_proj.geometry.y - right[:, 1]).max():.3f} m")
to_crs(proj.proj4_init): largest offset from Cartopy's own transform 0.000 m

Reprojecting the GeoDataFrame to the axes' CRS puts every point exactly where Cartopy's own transform does.

Right and wrong maps side by side

The data used on this page

The earthquakes of magnitude 4.5 or more in 2024 between 125Β° and 150Β° E and 24Β° and 46Β° N, around Japan, from the USGS FDSN event service.

from pathlib import Path

import cartopy.crs as ccrs
import cartopy.feature as cfeature
import geopandas as gpd
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
import pandas as pd
import requests

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)
J = Q[(Q["type"] == "earthquake") & Q.longitude.between(125, 150) & Q.latitude.between(24, 46)].reset_index(drop=True)
print(f"{len(J)} earthquakes around Japan in 2024")
511 earthquakes around Japan in 2024

511 earthquakes of magnitude 4.5 or more fell in the box around Japan in 2024.

Example 1: the right and wrong maps side by side

Draw the same points with and without transform and save both:

fig, axes = plt.subplots(1, 2, figsize=(10, 5), subplot_kw={"projection": proj})
for ax, kw, title in ((axes[0], {}, "no transform"), (axes[1], {"transform": ccrs.PlateCarree()}, "transform=ccrs.PlateCarree()")):
    ax.set_extent([125, 150, 24, 46], crs=ccrs.PlateCarree())
    ax.add_feature(cfeature.LAND.with_scale("50m"), facecolor="#e2e8f0"); ax.coastlines("50m", linewidth=0.4)
    ax.scatter(J.longitude, J.latitude, s=8, color="#ef4444", **kw)
    ax.set_title(title)
fig.savefig("data/transform_compare.png", dpi=120, bbox_inches="tight"); plt.close(fig)
centre = proj.transform_point(0, 0, proj)
lon0, lat0 = ccrs.PlateCarree().transform_point(*centre, proj)
print(f"without transform the points sit at the projection origin, {lon0:.1f}Β°E, {lat0:.1f}Β°N")
without transform the points sit at the projection origin, 138.0Β°E, 36.0Β°N

Without a transform, the points sit at the projection's origin, 138.0Β° E, 36.0Β° N, the centre of the Lambert projection in central Honshu: on this map they look like a single earthquake inland.

How Cartopy places data on a map. from data to mapdatacoordinateslon/lat, UTM...transform=names themreprojectionby Cartopyaxesprojectionmetresdrawnno transform: readas metresEvery plotting call needs its own transform spatialworkflow.io
How Cartopy places data.

Axes projection against data transform

Questions to ask when Cartopy points are in the wrong place. debugging misplaced points1what units is the data in?degrees or metres2did the call pass transform=?scatter, plot, text, imshow3is it the data's CRS?UTM data: ccrs.UTM(zone)4limits in degrees?set_extent, not set_xlim5GeoPandas?transform= or to_crsCoastlines right, points wrong: look at the transform first spatialworkflow.io
Debugging misplaced points.

Cartopy keeps two coordinate systems apart. The axes have a projection, set once, which fixes how the map is drawn and what units its x and y have: metres for almost every projection. Each artist, whether points, lines, polygons or images, has a transform naming the coordinate system of its own data. Cartopy reprojects from the second to the first. When the transform is missing, matplotlib's default applies, and the data is assumed to be in the axes' units already, so longitudes and latitudes of a few hundred become metres from the projection's origin, which on most maps is its centre. On a PlateCarree map the two happen to coincide, which is why code that worked on a simple map breaks as soon as the projection changes.

The same logic covers every variant. Projected data needs its projected CRS as the transform, limits in degrees go through set_extent rather than set_xlim, and GeoPandas, which knows nothing about Cartopy axes, needs either a transform or a GeoDataFrame reprojected to the axes' CRS. When points are in the wrong place, the first question is always: what coordinates is this data in, and did the plotting call say so?

Lines, rasters and 0-360 longitudes

Misplaced Cartopy points: symptoms and fixes. symptom to fixSymptomFixone dot at the centretransform=ccrs.PlateCarree()points vanishthe data's projected CRSmap shows almost nothingset_extent with crslabels in the wrong placetransform= on ax.textCartopy 0.26 spatialworkflow.io
Symptom to fix.
  • Lines: transform=ccrs.Geodetic() draws great circles between points, PlateCarree() straight lines in longitude and latitude.
  • Images and rasters: imshow needs both transform= and extent= in that transform's coordinates.
  • Longitudes from 0 to 360: PlateCarree accepts them; no conversion is needed.
  • Text and annotations: ax.text(lon, lat, "label", transform=ccrs.PlateCarree()) too.

FAQ

Why are my points in the wrong place on a Cartopy map?

The plotting call has no transform, or the wrong one. Longitude and latitude data needs transform=ccrs.PlateCarree().

Why are all my Cartopy points in one spot in the middle?

Without transform, longitude and latitude are read as metres in the map projection, so every point lands within a few hundred metres of the centre.

What transform do I use for UTM data in Cartopy?

ccrs.UTM(zone), with southern_hemisphere=True for the southern zones.

How do I plot a GeoDataFrame on a Cartopy map?

Pass transform=ccrs.PlateCarree() for EPSG:4326 data, or reproject with gdf.to_crs(ax.projection.proj4_init).