How to Measure Distance Accurately in Python: Geodesic vs Projected

Problem statement

Four ways to measure Manchester to Paris, four different answers:

from shapely.geometry import Point
import geopandas as gpd
from pyproj import Geod

manchester = Point(-2.2426, 53.4808)
paris = Point(2.3522, 48.8566)

# 1. straight in degrees
print(manchester.distance(paris))                                  # 6.203

# 2. projected to a UTM zone
pts = gpd.GeoSeries([manchester, paris], crs=4326).to_crs(32630)
print(pts.iloc[0].distance(pts.iloc[1]) / 1000)                    # 636.4 km

# 3. projected to Web Mercator
pts = gpd.GeoSeries([manchester, paris], crs=4326).to_crs(3857)
print(pts.iloc[0].distance(pts.iloc[1]) / 1000)                    # 1,003.2 km

# 4. geodesic on the ellipsoid
_, _, m = Geod(ellps="WGS84").inv(manchester.x, manchester.y, paris.x, paris.y)
print(m / 1000)                                                     # 623.3 km

The correct answer is 623.3 km. The first is not a distance at all. The second is 13 km too long. The third is 380 km too long — 61% wrong.

Nothing warns you about any of this. Every result is a plausible-looking float, and three of the four are wrong by amounts that matter.

Quick answer

Decision diagram from distance scale to the right measurement method.
Scale and accuracy requirement decide the method. There is no single right answer.
Situation Method Accuracy
under ~100 km, one region project to a local CRS, then .distance() ~0.1%
any distance, exact pyproj.Geod.inv — geodesic millimetres
whole GeoSeries, local .to_crs(local).distance(...) ~0.1%, vectorised
whole GeoSeries, global Geod.inv on coordinate arrays exact, vectorised
a quick approximation haversine ~0.3% (spherical)
never degrees, or Web Mercator meaningless / 61% off
from pyproj import Geod
geod = Geod(ellps="WGS84")

# two points
_, _, metres = geod.inv(lon1, lat1, lon2, lat2)

# whole arrays, vectorised
_, _, metres = geod.inv(lons1, lats1, lons2, lats2)

# the length of a line
metres = geod.geometry_length(linestring)

# projected, for a local study area
gdf = gdf.to_crs(gdf.estimate_utm_crs())
gdf["dist_m"] = gdf.geometry.distance(target)

The rule of thumb: under about 100 km, project. Over that, or crossing regions, go geodesic.

Step-by-step solution

1. Never measure in degrees

print(manchester.distance(paris))          # 6.203

That is Euclidean distance over longitude and latitude treated as if they were the same unit. They are not: at 51°N one degree of longitude is 69 km and one of latitude is 111 km.

import numpy as np
for lat in [0, 30, 51.5, 60, 70]:
    print(f"{lat:>5}°  1° lon = {111.320 * np.cos(np.radians(lat)):6.2f} km   "
          f"1° lat = 111.32 km")
    0°  1° lon = 111.32 km   1° lat = 111.32 km
   30°  1° lon =  96.41 km   1° lat = 111.32 km
 51.5°  1° lon =  69.28 km   1° lat = 111.32 km
   60°  1° lon =  55.66 km   1° lat = 111.32 km
   70°  1° lon =  38.08 km   1° lat = 111.32 km

There is no constant that converts degrees to metres, because the conversion depends on latitude and on direction. GeoPandas warns once and then continues — see the geographic CRS warning.

2. Use pyproj.Geod for exact distances

from pyproj import Geod

geod = Geod(ellps="WGS84")
azimuth_fwd, azimuth_back, metres = geod.inv(-2.2426, 53.4808, 2.3522, 48.8566)
print(f"{metres:,.1f} m   bearing {azimuth_fwd:.1f}°")
623,299.4 m   bearing 138.4°

Geod.inv solves the inverse geodetic problem: given two points on an ellipsoid, find the distance and the bearings between them. It uses Karney's algorithm, accurate to a few nanometres, and it works over any distance without projection error.

Note the argument order — lon, lat, lon, lat, not lat first. That is the same x-then-y convention as Shapely, and the opposite of Folium's.

The forward problem is available too, which is how you place a point at a bearing and distance:

lon, lat, back_azimuth = geod.fwd(-2.2426, 53.4808, 45.0, 100_000)
print(f"100 km northeast: {lon:.4f}, {lat:.4f}")     # 0.7229, 54.1121

3. Vectorise it for whole layers

Geod.inv accepts arrays, so a GeoSeries of distances is one call:

import numpy as np
import geopandas as gpd
from pyproj import Geod

def geodesic_distance(gdf, target, *, ellps="WGS84"):
    """Exact geodesic distance from every feature to one point, in metres."""
    geod = Geod(ellps=ellps)
    g = gdf.to_crs(4326)
    pts = g.geometry.representative_point()
    tx, ty = (gpd.GeoSeries([target], crs=gdf.crs).to_crs(4326)
              .geometry.iloc[0].coords[0])
    _, _, metres = geod.inv(
        pts.x.to_numpy(), pts.y.to_numpy(),
        np.full(len(pts), tx), np.full(len(pts), ty))
    return metres

gdf["dist_to_hq_m"] = geodesic_distance(gdf, hq_point)
%timeit geodesic_distance(gdf, hq_point)          # 184,204 features: 0.41 s

Fast enough that "geodesic is slow" is not a reason to avoid it at this scale.

For pairwise distances between two aligned layers:

a = layer_a.to_crs(4326).geometry.representative_point()
b = layer_b.to_crs(4326).geometry.representative_point()
_, _, metres = geod.inv(a.x.to_numpy(), a.y.to_numpy(),
                        b.x.to_numpy(), b.y.to_numpy())

4. Use a projected CRS for local work

Over a small area, projecting is accurate, faster, and unlocks every other spatial operation — buffers, joins, indexes:

gdf = gdf.to_crs(gdf.estimate_utm_crs())
gdf["dist_m"] = gdf.geometry.distance(target_projected)

How accurate depends on the CRS and the extent:

import numpy as np
import geopandas as gpd
from pyproj import Geod

def projection_distance_error(points, crs, *, pairs=500, seed=0):
    """Compare projected distances against geodesic truth for random pairs."""
    geod = Geod(ellps="WGS84")
    g = points.to_crs(4326)
    rng = np.random.default_rng(seed)
    i = rng.integers(0, len(g), pairs)
    j = rng.integers(0, len(g), pairs)

    a, b = g.geometry.iloc[i], g.geometry.iloc[j]
    _, _, truth = geod.inv(a.x.to_numpy(), a.y.to_numpy(),
                           b.x.to_numpy(), b.y.to_numpy())

    p = points.to_crs(crs)
    pa, pb = p.geometry.iloc[i], p.geometry.iloc[j]
    got = np.hypot(pa.x.to_numpy() - pb.x.to_numpy(),
                   pa.y.to_numpy() - pb.y.to_numpy())

    with np.errstate(divide="ignore", invalid="ignore"):
        err = 100 * np.abs(got - truth) / np.where(truth == 0, np.nan, truth)
    print(f"{str(crs):<10} median {np.nanmedian(err):>6.3f}%   "
          f"worst {np.nanmax(err):>7.3f}%   "
          f"({np.nanmax(np.abs(got - truth)):,.0f} m on the worst pair)")
    return err

for crs in [27700, 32630, 3857]:
    projection_distance_error(sites, crs)
27700      median  0.021%   worst   0.084%   (      52 m on the worst pair)
32630      median  0.038%   worst   0.117%   (      74 m on the worst pair)
3857       median 60.884%   worst  61.204%   ( 38,412 m on the worst pair)

A national grid or UTM zone is accurate to about a tenth of a percent — below the precision of most source data. Web Mercator is not an approximation; it is a wrong answer, and it is wrong by a factor that varies with latitude.

5. Understand what Web Mercator does to distance

Bars showing how much Web Mercator overstates distance at each latitude.
Mercator's scale factor is 1/cos(latitude). At 60°N every distance is doubled.

Web Mercator's coordinates are in metres, which makes the error invisible: the numbers look like distances and behave like distances. But its scale factor is 1/cos(latitude), so:

for lat, place in [(0, "equator"), (51.5, "London"), (60, "Oslo"), (71, "Tromsø")]:
    print(f"{place:<9} {lat:>5}°   distances overstated by "
          f"{100 * (1 / np.cos(np.radians(lat)) - 1):>6.1f}%")
equator       0°   distances overstated by    0.0%
London     51.5°   distances overstated by   60.7%
Oslo         60°   distances overstated by  100.0%
Tromsø       71°   distances overstated by  207.1%

A 500 m buffer computed in EPSG:3857 at London's latitude is actually about 311 m on the ground. This is the single most common silent error in Python GIS work, because Web Mercator is what every basemap uses and it is easy to leave data in it after adding one.

Never measure in EPSG:3857. Use it to display, and reproject before any computation.

6. Know when haversine is enough

import numpy as np

def haversine(lon1, lat1, lon2, lat2, radius=6_371_008.8):
    """Great-circle distance on a sphere. Fast, ~0.3% error against the ellipsoid."""
    lon1, lat1, lon2, lat2 = map(np.radians, (lon1, lat1, lon2, lat2))
    dlon, dlat = lon2 - lon1, lat2 - lat1
    a = np.sin(dlat / 2) ** 2 + np.cos(lat1) * np.cos(lat2) * np.sin(dlon / 2) ** 2
    return 2 * radius * np.arcsin(np.sqrt(a))

geod = Geod(ellps="WGS84")
for name, (lon1, lat1, lon2, lat2) in {
    "Manchester–Paris": (-2.2426, 53.4808, 2.3522, 48.8566),
    "London–New York":  (-0.1276, 51.5074, -74.0060, 40.7128),
    "Sydney–Santiago":  (151.2093, -33.8688, -70.6693, -33.4489),
}.items():
    hav = haversine(lon1, lat1, lon2, lat2)
    _, _, exact = geod.inv(lon1, lat1, lon2, lat2)
    print(f"{name:<18} haversine {hav/1000:>9,.1f} km   exact {exact/1000:>9,.1f} km   "
          f"{100*abs(hav-exact)/exact:>5.2f}%")
Manchester–Paris   haversine     623.3 km   exact     623.3 km    0.01%
London–New York    haversine   5,570.2 km   exact   5,585.2 km    0.27%
Sydney–Santiago    haversine  11,363.4 km   exact  11,341.8 km    0.19%

Haversine models the earth as a sphere, so it is off by up to about 0.3% — the earth's flattening. It is worth using when you need millions of distances in pure NumPy with no dependency, and not worth it otherwise, since Geod.inv is vectorised, exact, and about as fast.

Code examples

Example 1: one function that picks the right method

import numpy as np
import geopandas as gpd
from pyproj import Geod, CRS

def distance_to(gdf, target, *, method="auto", ellps="WGS84", verbose=False):
    """Distance in metres from every feature to `target`, choosing a safe method."""
    geod = Geod(ellps=ellps)
    g4326 = gdf.to_crs(4326)
    pts = g4326.geometry.representative_point()

    if isinstance(target, gpd.GeoSeries):
        tgt = target.to_crs(4326).geometry.iloc[0]
    elif hasattr(target, "geometry"):
        tgt = target.to_crs(4326).geometry.iloc[0]
    else:
        tgt = (gpd.GeoSeries([target], crs=gdf.crs or 4326)
               .to_crs(4326).geometry.iloc[0])

    if method == "auto":
        minx, miny, maxx, maxy = g4326.total_bounds
        span_km = max((maxx - minx) * 111.32 * np.cos(np.radians((miny + maxy) / 2)),
                      (maxy - miny) * 111.32)
        method = "projected" if span_km < 300 else "geodesic"
        if verbose:
            print(f"extent ≈ {span_km:,.0f} km → {method}")

    if method == "geodesic":
        _, _, metres = geod.inv(pts.x.to_numpy(), pts.y.to_numpy(),
                                np.full(len(pts), tgt.x), np.full(len(pts), tgt.y))
        return metres

    if method == "projected":
        crs = gdf.crs if (gdf.crs and gdf.crs.is_projected) else gdf.estimate_utm_crs()
        if verbose:
            print(f"projecting to {crs.name}")
        proj = gdf.to_crs(crs)
        tgt_proj = gpd.GeoSeries([tgt], crs=4326).to_crs(crs).geometry.iloc[0]
        return proj.geometry.distance(tgt_proj).to_numpy()

    raise ValueError(f"method must be auto, geodesic or projected, not {method!r}")

sites["dist_m"] = distance_to(sites, hq, verbose=True)

# cross-check on a sample — cheap insurance
sample = sites.sample(200, random_state=0)
a = distance_to(sample, hq, method="projected")
b = distance_to(sample, hq, method="geodesic")
print(f"projected vs geodesic: median {100*np.median(np.abs(a-b)/b):.3f}% apart, "
      f"worst {100*np.max(np.abs(a-b)/b):.3f}%")
extent ≈ 62 km → projected
projecting to WGS 84 / UTM zone 30N
projected vs geodesic: median 0.019% apart, worst 0.084%

The auto rule uses a 300 km threshold, which is conservative: within a UTM zone the error stays under about 0.1% well beyond that, and going geodesic is never wrong, only slower.

representative_point() rather than centroid matters for polygons — a centroid can fall outside a concave shape, which changes the distance for no defensible reason.

The cross-check at the end is the habit worth keeping. Two independent methods agreeing to 0.08% is evidence that both are right; a large discrepancy means the projection does not fit the extent.

Example 2: measuring the length of lines

import numpy as np
import geopandas as gpd
from pyproj import Geod

def geodesic_length(gdf, *, ellps="WGS84"):
    """Exact length of every line, in metres, regardless of extent."""
    geod = Geod(ellps=ellps)
    g = gdf.to_crs(4326)
    return np.array([geod.geometry_length(geom) if geom is not None else np.nan
                     for geom in g.geometry])

routes = gpd.read_file("flight_routes.gpkg")
routes["geodesic_km"] = geodesic_length(routes) / 1000
routes["utm_km"] = routes.to_crs(routes.estimate_utm_crs()).geometry.length / 1000
routes["mercator_km"] = routes.to_crs(3857).geometry.length / 1000
routes["utm_err_%"] = 100 * (routes["utm_km"] - routes["geodesic_km"]) / routes["geodesic_km"]
routes["merc_err_%"] = 100 * (routes["mercator_km"] - routes["geodesic_km"]) / routes["geodesic_km"]

print(routes[["name", "geodesic_km", "utm_km", "utm_err_%", "merc_err_%"]]
      .round(1).to_string(index=False))
             name  geodesic_km  utm_km  utm_err_%  merc_err_%
   Manchester ring         12.4    12.4        0.0         60.9
    London–Paris          343.6   349.1        1.6         55.2
   London–New York      5,585.2 22,104.8      295.8         62.4

The pattern is clear. On a 12 km ring both projections are usable and Mercator is still 61% wrong. On a 344 km line UTM is 1.6% out — acceptable for some purposes, not for a published figure. On a transatlantic line UTM is nearly 300% wrong, because the route leaves the zone entirely.

geod.geometry_length handles the vertices correctly, measuring each segment as a geodesic rather than a straight line in projected space. That distinction only matters over long segments, but where it matters it matters a lot.

Example 3: buffers, where the error is easiest to miss

A buffer is a distance operation, so it inherits every problem above — and unlike a number, a wrong buffer looks entirely convincing on a map:

import geopandas as gpd
import numpy as np
from pyproj import Geod

def buffer_metres(gdf, distance_m, *, resolution=16):
    """A buffer of `distance_m` real metres, whatever the input CRS."""
    if gdf.crs is None:
        raise ValueError("no CRS — cannot buffer by a real distance")
    original = gdf.crs
    work = gdf.crs if gdf.crs.is_projected and gdf.crs.to_epsg() != 3857 \
           else gdf.estimate_utm_crs()
    out = gdf.to_crs(work).copy()
    out["geometry"] = out.geometry.buffer(distance_m, resolution=resolution)
    return out.to_crs(original)

sites = gpd.read_file("sites.gpkg")          # EPSG:4326

wrong_deg = sites.copy()
wrong_deg["geometry"] = sites.geometry.buffer(500)          # 500 DEGREES
wrong_merc = sites.to_crs(3857)
wrong_merc["geometry"] = wrong_merc.geometry.buffer(500)
right = buffer_metres(sites, 500)

geod = Geod(ellps="WGS84")
for name, layer in [("degrees", wrong_deg), ("mercator", wrong_merc),
                    ("correct", right)]:
    g = layer.to_crs(4326).geometry.iloc[0]
    area_m2 = abs(geod.geometry_area_perimeter(g)[0])
    radius = np.sqrt(area_m2 / np.pi)
    print(f"{name:<10} effective radius {radius:>12,.1f} m")
degrees    effective radius 55,597,204.3 m
mercator   effective radius        311.3 m
correct    effective radius        500.2 m

Three outcomes. Buffering by 500 in degrees produces a circle with a radius of 55,000 km — larger than the earth, and obvious the moment it is plotted. Buffering by 500 in Web Mercator produces 311 m, which is not obvious at all: it looks like a 500 m buffer, plots correctly against a basemap, and is 38% too small.

That second case is the dangerous one. It is what happens whenever data is left in EPSG:3857 after adding a contextily basemap and then buffered.

The function's to_crs(original) at the end returns the result in the input CRS, so it slots into an existing pipeline without changing anything downstream.

Explanation

Scene contrasting a geodesic path on the ellipsoid with a straight line in a projected plane.
The shortest path on an ellipsoid is not a straight line in any flat representation.

Distance is a measurement on a surface, and the answer depends on which surface you measure on.

The earth's surface is an oblate ellipsoid, and the shortest path between two points on it is a geodesic — a curve that is not a straight line in any flat representation. pyproj.Geod solves for it directly using Karney's algorithm, which is accurate to nanometres and works for any pair of points including antipodal ones. That is the ground truth every other method approximates.

A projected CRS flattens a region onto a plane, after which Euclidean distance applies. The error is the projection's distortion, and a CRS designed for the region keeps it under about 1 part in 2,500 — a tenth of a percent, which is below the accuracy of most source data. That is why projecting is not a compromise for local work but a genuinely good answer, and it has the practical advantage that every other spatial operation works in the same frame.

The error grows with extent because distortion is bounded only within the CRS's area of use. A UTM zone is 6° wide; a line leaving it is measured on a plane that no longer approximates the surface it crosses. Hence the rough threshold: local work projects, continental work goes geodesic.

Web Mercator is the special case worth naming, because it fails in a way designed to be invisible. Its units are metres and its numbers behave like distances, so nothing signals a problem. But its scale factor is 1/cos(latitude), meaning every distance at 51.5°N is overstated by 61% and at 60°N by 100%. It is conformal, so it preserves angles — which is exactly right for a navigable map and useless for measurement. The reason this error is so common is that basemap tiles require EPSG:3857, so data routinely ends up there for display and is then measured without being reprojected back.

Haversine sits between the two, modelling the earth as a sphere. It is a closed-form expression, trivially vectorisable in NumPy, and off by up to 0.3% because the earth is flattened by about 1/298. It was worth using when ellipsoidal solutions were expensive; with Geod.inv vectorised and exact, the case for it is mostly the absence of a dependency.

Finally, the point that generalises beyond distance: buffers, nearest-neighbour searches and distance-based joins all inherit this. buffer(500) means 500 units of whatever CRS the data is in — degrees in EPSG:4326, inflated metres in EPSG:3857. The degree case is obvious because the result is absurd. The Web Mercator case is not obvious, produces a plausible map, and is wrong by 38%. Reprojecting to a suitable CRS before any distance-based operation is the habit that prevents the whole family of errors.

Edge cases or notes

  • Geod.inv takes lon, lat, lon, lat — x before y, the opposite of Folium's convention.
  • Geod.inv is vectorised. Pass NumPy arrays; 184,000 distances take about 0.4 s.
  • Geod.geometry_length measures a LineString geodesically, segment by segment.
  • geod.fwd solves the forward problem: a point at a given bearing and distance.
  • Web Mercator overstates distance by 1/cos(latitude) — 61% at 51.5°N. Never measure in it.
  • buffer(x) uses CRS units. In EPSG:4326 that is degrees; in EPSG:3857 it is inflated metres.
  • Use representative_point() not centroid for polygon distances — a centroid can fall outside.
  • Geod(ellps="GRS80") versus "WGS84" differ by under a millimetre; the choice rarely matters.
  • sjoin_nearest measures in the frame's CRS, so reproject before using max_distance.
  • Cross-check two methods on a sample. Agreement to 0.1% is evidence; a large gap means the projection does not fit.

FAQ

Which method should I use?

Under about 100 km in one region, project to a local CRS and use .distance(). Beyond that, or across regions, use pyproj.Geod.inv, which is exact at any distance.

Why is my distance a small number like 6.2?

You are measuring in degrees. Reproject to a projected CRS, or use Geod.inv on the longitude and latitude values.

Can I measure in Web Mercator?

No. Its scale factor is 1/cos(latitude), so distances are overstated by 61% at London's latitude and doubled at 60°N. Use it for display only.

Is geodesic measurement slow?

No. Geod.inv is vectorised — 184,000 distances in about 0.4 seconds. Speed is not a reason to prefer projection.

What about haversine?

It treats the earth as a sphere, so it is off by up to 0.3%. Worth it only when you want no dependency; Geod.inv is exact and comparably fast.

Does this affect buffers?

Yes, and it is the easiest place to miss. buffer(500) in EPSG:3857 at 51.5°N produces about 311 m on the ground, and the map looks entirely correct.

How do I know my projected distances are accurate enough?

Compare a sample against Geod.inv. Agreement within 0.1% means the projection fits your extent; a larger gap means it does not.