Reprojecting CRS for Accurate Distance Calculations

TL;DR: Never compute distances, buffers, or nearest-neighbour joins on EPSG:4326 (longitude/latitude degrees). Detect the right metric CRS with utm = gdf.estimate_utm_crs(), reproject with gdf_m = gdf.to_crs(utm), then confirm gdf_m.crs.axis_info[0].unit_name == "metre". All subsequent .distance(), .buffer(), and sjoin_nearest(max_distance=...) calls now return and accept metres.

Why This Matters

A degree of longitude is roughly 111 km at the equator and zero at the poles, so treating latitude/longitude as if they were planar coordinates makes every Euclidean distance wrong — and wrong by a factor that changes with latitude. Buffers become ellipses of unintended size, sjoin_nearest matches the wrong features, and any distance-based spatial weight matrix or variogram is built on nonsense lags. This page is a core step in the GeoPandas Data Preparation guide, and correct units underpin everything downstream in the Python Workflows for Spatial Modeling & Regression stack — kriging ranges, cross-validation buffers, and neighbourhood definitions all assume metric coordinates.

The fix is one reprojection at the start of the pipeline, done once, verified once. Getting it right removes a whole class of silent errors that otherwise surface only as inexplicably poor model performance much later.

The Problem, Formally

Euclidean distance between two points assumes orthogonal axes in the same unit:

d=(Δx)2+(Δy)2.d = \sqrt{(\Delta x)^2 + (\Delta y)^2}.

In EPSG:4326, Δx\Delta x is a longitude difference in degrees and Δy\Delta y a latitude difference in degrees, but a degree of longitude spans

111,320cos(ϕ) metres111{,}320 \cdot \cos(\phi)\ \text{metres}

at latitude ϕ\phi, while a degree of latitude is a near-constant 110,540\approx 110{,}540 m. The two axes are in incompatible physical units, so the formula above is invalid. A projected CRS maps the ellipsoid onto a plane where both axes are metres, restoring the identity.

A degree cell drawn to scale at three latitudes Three rectangles, each one degree of latitude tall and one degree of longitude wide, drawn to a common scale. All three are 150 pixels tall because a degree of latitude is a near-constant 110,540 metres, but the widths shrink with the cosine of latitude: 111,320 metres at the equator, 78,715 metres at 45 degrees and 55,660 metres at 60 degrees. A violet diagonal in each cell is labelled with its true ground length, 156.9, 135.7 and 123.8 kilometres, while the naive Euclidean value on degrees is 1.414 in every case. The same 1° × 1° step is a different distance at every latitude Euclidean distance on degrees returns √(1² + 1²) = 1.414 for all three cells — but the ground distance differs by more than 25%. 110,540 m 1° lon = 111,320 m φ = 0° (equator) √2 = 1.414° true: 156.9 km 1° lon = 78,715 m φ = 45° √2 = 1.414° true: 135.7 km 1° lon = 55,660 m φ = 60° √2 = 1.414° true: 123.8 km Why the shapes differ Δy = 1° lat ≈ 110,540 m near-constant everywhere Δx = 1° lon = 111,320·cosφ 111.3 km at 0°, 0 at the pole Two axes, two units — the √ formula is invalid until both of them are metres. Project first: in a UTM CRS both axes are metres, so d = √(Δx² + Δy²) is a real distance again.

Environment

bash
pip install \
  geopandas==0.14.4 \
  pyproj==3.6.1 \
  shapely==2.0.4 \
  pyogrio==0.7.2
python
import geopandas as gpd
from shapely.geometry import Point

gpd.options.io_engine = "pyogrio"

Step-by-Step Implementation

Step 1 — Inspect the Current CRS and Its Units

Before anything, find out whether the data are in degrees. crs.axis_info exposes the axis unit; crs.is_geographic is True for lat/lon systems.

python
gdf = gpd.read_file("stations.geojson")   # commonly EPSG:4326
print(gdf.crs)
print("geographic?", gdf.crs.is_geographic)
print("unit:", gdf.crs.axis_info[0].unit_name)
# EPSG:4326 -> geographic? True   unit: degree

If is_geographic is True, any distance you compute now is invalid until you reproject.

Step 2 — Estimate the Correct UTM Zone

estimate_utm_crs reads the data extent and returns the UTM CRS covering it, with the correct hemisphere. This avoids manually looking up zone numbers.

python
utm_crs = gdf.estimate_utm_crs()
print(utm_crs)                    # e.g. EPSG:32633 (UTM zone 33N)
print("unit:", utm_crs.axis_info[0].unit_name)   # metre

UTM is the right default when your study area spans up to a few hundred kilometres and you care about local distance fidelity.

Step 3 — Reproject with to_crs

to_crs transforms every geometry into the target CRS. It returns a new GeoDataFrame; the original is untouched.

python
gdf_m = gdf.to_crs(utm_crs)
print(gdf_m.crs.axis_info[0].unit_name)   # metre

For continental or global area calculations, target an equal-area CRS instead, for example an Albers or Lambert azimuthal equal-area definition appropriate to your region:

python
# Example: Europe Lambert azimuthal equal-area
gdf_ea = gdf.to_crs("EPSG:3035")

Step 4 — Verify Units and a Known Distance

Trust nothing until a known distance checks out. Compute the separation between two points whose real-world distance you can sanity-check.

python
assert gdf_m.crs.axis_info[0].unit_name == "metre", "Not a metric CRS"

# Distance between the first two stations, now in metres
d = gdf_m.geometry.iloc[0].distance(gdf_m.geometry.iloc[1])
print(f"Separation: {d:,.1f} m")

# Compare against the geodesic (true ellipsoidal) distance as a cross-check
geod = gdf.crs.get_geod() if gdf.crs.get_geod() else None

A projected distance within a fraction of a percent of the geodesic distance confirms the projection is suitable for the area. Large discrepancies mean the extent is too big for a single UTM zone.

How far a single UTM zone can be trusted An annotated line chart. The horizontal axis is distance from the zone's central meridian, from 0 to 900 kilometres; the vertical axis is scale error from 0 to 1 percent. The curve starts at minus 0.04 percent where the scale factor is 0.9996, crosses zero at 180 kilometres, reaches plus 0.097 percent at the 334 kilometre zone edge with the band up to there shaded green, then leaves the zone and climbs through plus 0.27 percent at 500 kilometres, plus 0.56 percent at 700 kilometres and plus 0.96 percent at 900 kilometres in the shaded amber region. How far can one UTM zone be trusted? Transverse Mercator scale error against distance from the central meridian (k₀ = 0.9996) 1.00% 0.75% 0.50% 0.25% 0% scale error of a measured distance 0 180 334 500 700 900 distance from the zone’s central meridian (km) zone edge · 3° ≈ 334 km k = 0.9996 on the meridian: −0.04% true scale, k = 1 at ±180 km from the meridian +0.097% at the edge +0.56% at 700 km ≈ 560 m wrong per 100 km measured +0.96% at 900 km Reading the curve e(x) = 0.9996·(1 + x²/2R²) − 1 x = distance from the central meridian Inside the zone error stays under 0.1%, so a projected separation matches the geodesic one to better than 1 m per km. Beyond the zone +0.27% at 500 km, +0.56% at 700 km, +0.96% at 900 km — the signal to move to a regional or custom CRS. R = 6,371 km (mean Earth radius) The geodesic cross-check in Step 4 measures exactly this curve on your own data.

Step 5 — Run Distance-Dependent Operations Safely

With metric coordinates, buffers and nearest joins now take and return metres.

python
# 500 m buffer around each station
buffers = gdf_m.copy()
buffers["geometry"] = gdf_m.geometry.buffer(500)

# Nearest-neighbour join with a real 1 km distance cap
from_gdf = gdf_m
matched = gpd.sjoin_nearest(from_gdf, gdf_m, max_distance=1000, how="left")

Interpreting the Output

After Step 4, gdf_m.crs.axis_info[0].unit_name must read metre and a spot-checked separation must match reality. If a coastal station 30 km from its neighbour reports 0.27 in the projected CRS, you are still in degrees; if it reports 29,980 m, you are correct. The geodesic cross-check matters most near a UTM zone edge, where projected distance distortion grows: UTM error stays under about 0.1% within its 6-degree-wide zone but climbs beyond it, which is the practical signal that your area needs a custom projection or a wider equal-area CRS.

Once units are confirmed metric, every downstream metre-valued parameter — a kriging range, a buffered-CV exclusion radius, a DistanceBand threshold — is finally interpretable and portable.

Critical Best Practices

Reproject Once, Early, and Assert

Do the reprojection at ingestion and immediately assert not gdf.crs.is_geographic. Threading degree-CRS data deep into a pipeline before someone computes a distance is how silent errors survive to production. Systematic harmonisation across all inputs is the job of the parent GeoPandas Data Preparation workflow.

Align Every Layer to the Same CRS Before Joining

sjoin, sjoin_nearest, and overlay silently mis-locate features if layers differ in CRS, and GeoPandas will warn but still run. Reproject all participating layers to the one chosen metric CRS first; never mix a degree layer with a metre layer in a spatial operation.

Choose the Projection by Task, Not Habit

UTM preserves local distance and angle but distorts area far from its central meridian; equal-area projections preserve area but distort distance. Pick by what you will measure: distances and buffers favour UTM; density, area-normalised rates, and zonal statistics favour an equal-area CRS. Do not reuse a web-mapping EPSG:3857 (Web Mercator) for measurement — it distorts distance badly away from the equator.

Choosing the target CRS by the measurement you intend to make A three-way decision tree. The root asks what a GeoDataFrame in EPSG:4326 will be used for. The distance branch splits into two outcomes: a local UTM zone from gdf.estimate_utm_crs when the extent fits one six-degree zone, and a custom transverse Mercator or regional CRS when the extent spans several zones and error passes half a percent by 700 kilometres. The area branch leads to an equal-area CRS such as EPSG:3035. The display branch leads to EPSG:3857 Web Mercator, marked as tiles only and never to be measured on. A footer states that every layer must be reprojected to the same CRS before any join. Choose the projected CRS by what you will measure gdf.crs is EPSG:4326 (degrees) What will these coordinates be used for? Distances, buffers, joins sjoin_nearest(max_distance=1000) Areas and densities zonal stats, rates per km² Web-map display only nothing is measured Local UTM zone utm = gdf.estimate_utm_crs() e.g. EPSG:32633 · unit: metre extent inside one 6° zone Extent spans zones error passes 0.5% by 700 km custom transverse Mercator or a regional equal-area CRS Equal-area CRS gdf.to_crs("EPSG:3035") Albers or LAEA for the region areas exact, distances distorted EPSG:3857 — tiles only Web Mercator for basemaps distance error grows with latitude — never measure on it Whichever branch you take, reproject every participating layer to that one CRS before any join — then prove it: assert gdf_m.crs.axis_info[0].unit_name == "metre"

Reproject Vectors, Not Rasters, When Sampling

When aligning points to a raster, reproject the points to the raster CRS rather than resampling the raster to the points. Reprojecting a raster resamples every pixel and changes values; moving the vector layer is exact and cheap. This mirrors the guidance in Chunked Raster Processing with Dask-GeoPandas.

Watch Antimeridian and Polar Edge Cases

estimate_utm_crs fails gracefully for most areas but a study region straddling the antimeridian or a pole needs a deliberate polar or custom CRS. Check the returned zone against your known geography rather than trusting it blindly for global datasets.

Troubleshooting

Symptom Likely cause Fix
Distances are tiny decimals (< 1) Still in EPSG:4326 degrees Reproject with to_crs(gdf.estimate_utm_crs())
Buffers look like ellipses on a map Buffered in degrees, mapped in metres Buffer in a metric CRS, then reproject for display
sjoin_nearest matches wrong features Layers in different CRS Reproject all layers to one metric CRS before the join
Distances off by ~0.5% at region edge Study area exceeds one UTM zone Use a regional equal-area or custom transverse Mercator CRS
estimate_utm_crs returns wrong hemisphere Data crosses the equator or is mislabelled Set the CRS explicitly; verify latitude signs
CRS warning on sjoin Mixed geographic and projected inputs Harmonise CRS at ingestion; assert not is_geographic

Next Steps

With coordinates in metres, distance-based joins scale better too; see Optimizing GeoPandas Spatial Joins for Large Datasets for index strategies, and return to the parent GeoPandas Data Preparation guide for the full cleaning and harmonisation sequence.


Related:

← Back to GeoPandas Data Preparation