Projected vs Geographic CRS: What Actually Changes When You Reproject
On this page
Problem statement
Ask a layer stored in longitude and latitude for its area, and GeoPandas answers with a warning and a number:
import warnings
import geopandas as gpd
gdf = gpd.read_file("wards.gpkg") # Leeds' 33 wards, EPSG:4326
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
gdf.geometry.area
print(caught[0].message)
Geometry is in a geographic CRS. Results from 'area' are likely incorrect. Use 'GeoSeries.to_crs()' to re-project geometries to a projected CRS before this operation.
The number is not wrong arithmetic; it is a different quantity. Here is what it means:
gdf_wgs84 = gpd.read_file("wards.gpkg") # EPSG:4326
gdf_bng = gdf_wgs84.to_crs(27700) # EPSG:27700
with warnings.catch_warnings():
warnings.simplefilter("ignore")
print(f"{gdf_wgs84.geometry.area.iloc[0]:.8f} ← square degrees")
print(f"{gdf_bng.geometry.area.iloc[0]:,.1f} ← square metres")
0.00245530 ← square degrees
18,023,955.5 ← square metres
A square degree is not a unit of area. It is roughly 12,400 km² at the equator, roughly 7,300 km² at Leeds' 53.8°N, and zero at the poles. The first number cannot be converted to the second by multiplying by a constant, because there is no constant.
The distinction between geographic and projected coordinate systems is the single most consequential thing to understand about CRS, and it decides whether your measurements mean anything.
Quick answer
| Geographic | Projected | |
|---|---|---|
| coordinates are | angles on an ellipsoid | distances on a plane |
| units | degrees | metres (usually) |
| example | EPSG:4326 (WGS 84) | EPSG:27700, 32630, 3857 |
.area returns |
square degrees — meaningless | square metres — usable |
.distance returns |
degrees — meaningless | metres — usable |
.buffer(500) means |
500 degrees | 500 metres |
| covers | the whole earth | one region, well |
| distortion | none (it is not flat) | unavoidable, bounded by design |
print(gdf.crs.is_geographic) # True → do not measure
print(gdf.crs.is_projected) # False
print(gdf.crs.axis_info[0].unit_name) # 'degree'
gdf = gdf.to_crs(27700) # now measurements are metres
One rule covers nearly every case: store and share in a geographic CRS, measure and analyse in a projected one.
Which kind, what a degree is, what reprojection does
1. Check which kind you have
import geopandas as gpd
def describe_crs(gdf):
crs = gdf.crs
if crs is None:
return "no CRS — the coordinates mean nothing on their own"
unit = crs.axis_info[0].unit_name
print(f"name {crs.name}")
print(f"epsg {crs.to_epsg()}")
print(f"kind {'geographic' if crs.is_geographic else 'projected'}")
print(f"units {unit}")
print(f"datum {crs.datum.name if crs.datum else '—'}")
print(f"bounds {crs.area_of_use.bounds if crs.area_of_use else '—'}")
print(f"measurable {'no — reproject first' if crs.is_geographic else 'yes'}")
describe_crs(gpd.read_file("wards.gpkg"))
name WGS 84
epsg 4326
kind geographic
units degree
datum World Geodetic System 1984 ensemble
bounds (-180.0, -90.0, 180.0, 90.0)
measurable no — reproject first
The coordinate values are also a giveaway. Anything within ±180 and ±90 is almost certainly degrees; six-figure numbers are almost certainly metres.
2. Understand what a degree actually is
A degree of latitude is nearly constant — about 111.32 km everywhere, because meridians are great circles.
A degree of longitude shrinks with latitude, because meridians converge toward the poles:
import numpy as np
for lat in [0, 30, 53.8, 60, 70, 80]:
lon_km = 111.320 * np.cos(np.radians(lat))
print(f"{lat:>5}° 1° lat = 111.32 km 1° lon = {lon_km:6.2f} km "
f"ratio {111.320 / lon_km:.2f}")
0° 1° lat = 111.32 km 1° lon = 111.32 km ratio 1.00
30° 1° lat = 111.32 km 1° lon = 96.41 km ratio 1.15
53.8° 1° lat = 111.32 km 1° lon = 65.75 km ratio 1.69
60° 1° lat = 111.32 km 1° lon = 55.66 km ratio 2.00
70° 1° lat = 111.32 km 1° lon = 38.07 km ratio 2.92
80° 1° lat = 111.32 km 1° lon = 19.33 km ratio 5.76
This is why the degree-based numbers cannot be salvaged. At Leeds' latitude one unit of x is about 66 km and one unit of y is 111 km, so treating them as the same unit — which is exactly what area and distance do — is wrong by about 70% before any other consideration. (These use a spherical 111.32 km per degree; on the ellipsoid a degree of latitude varies between about 110.6 and 111.7 km.)
from shapely.geometry import Point
a, b = Point(-2.0, 53.0), Point(-1.0, 53.0) # 1° of longitude apart
print(a.distance(b), "← degrees")
print(f"{gpd.GeoSeries([a], crs=4326).to_crs(27700).distance(gpd.GeoSeries([b], crs=4326).to_crs(27700)).iloc[0]:,.1f} ← metres")
1.0 ← degrees
67,113.0 ← metres
3. Know what reprojection actually does
to_crs transforms every coordinate. Three things happen, and they are worth separating:
The coordinate values change. Leeds station, (-1.5474, 53.7952) in WGS 84, becomes roughly (429,900, 433,300) in British National Grid. Same place, different reference frame.
Measurements become meaningful. The transformation maps angular positions onto a plane where one unit is one metre, so Euclidean arithmetic is valid.
Distortion is introduced. Flattening a curved surface must stretch something. A projection designed for your area keeps that under about 1 part in 2,500; one designed for elsewhere does not.
Two things that look similar are different operations: relabelling a layer with set_crs changes no coordinates at all (set_crs vs to_crs), and converting between two datums, such as WGS 84 and OSGB36, moves positions by up to about 120 m in Britain and needs a grid file (reprojecting between datums).
4. Choose the right projected CRS
Projected systems are designed for a region. Using one outside its area of use produces large errors:
from pyproj import CRS
for epsg in [27700, 2154, 32630, 3857, 5070]:
crs = CRS.from_epsg(epsg)
aou = crs.area_of_use
print(f"{epsg:>6} {crs.name[:38]:<38} {aou.name[:34] if aou else '—'}")
27700 OSGB36 / British National Grid United Kingdom (UK) - offshore to
2154 RGF93 v1 / Lambert-93 France - onshore and offshore, mai
32630 WGS 84 / UTM zone 30N Between 6°W and 0°W, northern hemi
3857 WGS 84 / Pseudo-Mercator World between 85.06°S and 85.06°N.
5070 NAD83 / Conus Albers United States (USA) - CONUS onshor
def check_area_of_use(gdf):
"""Is this layer inside its CRS's intended area?"""
aou = gdf.crs.area_of_use
if aou is None:
return "no declared area of use"
from shapely.geometry import box
valid = box(*aou.bounds)
extent = box(*gdf.to_crs(4326).total_bounds)
if valid.contains(extent):
return f"✓ inside {aou.name}"
return f"✗ extends beyond {aou.name} — distortion is not bounded"
print(check_area_of_use(gdf.to_crs(27700)))
print(check_area_of_use(gdf.to_crs(2154))) # Leeds in the French national grid
✓ inside United Kingdom (UK) - offshore to boundary of UKCS within 49°45'N to 61°N and 9°W to 2°E; onshore Great Britain (England, Wales and Scotland). Isle of Man onshore.
✗ extends beyond France - onshore and offshore, mainland and Corsica (France métropolitaine including Corsica). — distortion is not bounded
EPSG:3857 is the exception whose area of use is the world, and it pays for that with area inflation of 1/cos²(latitude) — 4× at 60°N. It is right for web tiles and wrong for measurement. Full treatment in choosing a map projection for display.
5. Know when a geographic CRS is correct
Geographic is not "wrong" — it is the right choice for several things:
- Storage and interchange. GeoJSON specifies WGS 84; APIs and GPS return it. It is the lingua franca.
- Global data. No single projected CRS covers the world with acceptable distortion.
- Geodesic calculations. For long distances, computing on the ellipsoid is more accurate than projecting:
from pyproj import Geod
geod = Geod(ellps="WGS84")
_, _, metres = geod.inv(-1.5474, 53.7952, 2.3522, 48.8566) # Leeds station → Paris
print(f"geodesic {metres / 1000:,.1f} km")
ends = gpd.GeoSeries.from_xy([-1.5474, 2.3522], [53.7952, 48.8566], crs=4326)
for epsg in (27700, 32630, 3857):
p = ends.to_crs(epsg)
print(f"EPSG:{epsg:<6} {p.iloc[0].distance(p.iloc[1]) / 1000:,.1f} km")
geodesic 612.8 km
EPSG:27700 612.7 km
EPSG:32630 613.0 km
EPSG:3857 982.1 km
The geodesic figure is exact on the ellipsoid. The projected ones are Euclidean distances on a plane that was designed for somewhere else, over a line crossing 3.9 degrees of longitude — and Web Mercator is off by far more than either national or UTM grid. See how to measure distance accurately.
The rule of thumb: under a few hundred kilometres, project and measure. Over that, or crossing zones, use geodesic functions.
A degree guard and the cost of a projection on Leeds
The data used on this page
Leeds' 33 electoral wards (December 2024, full resolution) from the ONS, and HM Land Registry's INSPIRE Index Polygons for Leeds — every registered freehold title — both saved in WGS 84 as wards.gpkg and parcels.gpkg, the way such layers usually arrive. Land Registry data © Crown copyright and database right 2026, Open Government Licence v3.0.
import geopandas as gpd
import pyogrio
import requests
ONS = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services"
wards = gpd.read_file(f"{ONS}/Wards_December_2024_Boundaries_UK_BFC/FeatureServer/0/query"
"?where=LAD24NM%3D%27Leeds%27&outFields=WD24CD,WD24NM&outSR=4326&f=geojson")
wards.to_file("wards.gpkg")
# HM Land Registry's download sets a cookie and redirects to itself, so use a session
reply = requests.Session().get("https://use-land-property-data.service.gov.uk/datasets/inspire/download/Leeds.zip",
timeout=600)
reply.raise_for_status()
with open("leeds_inspire.zip", "wb") as f:
f.write(reply.content)
pyogrio.set_gdal_config_options({"GML_DOWNLOAD_SCHEMA": "NO"}) # the file's schema URL is unreachable
titles = gpd.read_file("zip://leeds_inspire.zip!Land_Registry_Cadastral_Parcels.gml", columns=["INSPIREID"])
titles.to_crs(4326).to_file("parcels.gpkg")
print(f"{len(wards)} wards and {len(titles):,} titles written in EPSG:4326")
33 wards and 293,729 titles written in EPSG:4326
Example 1: a guard that refuses to measure in degrees
import functools
import warnings
import geopandas as gpd
def requires_projected(fn):
"""Refuse to run a measurement on a geographic CRS."""
@functools.wraps(fn)
def wrapper(gdf, *args, **kwargs):
if gdf.crs is None:
raise ValueError(
f"{fn.__name__}: the layer has no CRS. Identify it with set_crs "
f"before measuring — do not guess.")
if gdf.crs.is_geographic:
raise ValueError(
f"{fn.__name__}: {gdf.crs.name} is geographic, so area and distance "
f"are in degrees. Call .to_crs(gdf.estimate_utm_crs()) or a national "
f"grid first.")
return fn(gdf, *args, **kwargs)
return wrapper
@requires_projected
def total_area_km2(gdf):
return gdf.geometry.area.sum() / 1e6
@requires_projected
def buffer_metres(gdf, distance_m):
out = gdf.copy()
out["geometry"] = gdf.geometry.buffer(distance_m)
return out
wards = gpd.read_file("wards.gpkg") # EPSG:4326
try:
total_area_km2(wards)
except ValueError as exc:
print(exc)
print(f"{total_area_km2(wards.to_crs(27700)):,.1f} km²")
total_area_km2: WGS 84 is geographic, so area and distance are in degrees. Call .to_crs(gdf.estimate_utm_crs()) or a national grid first.
551.7 km²
Raising rather than warning is the deliberate choice. GeoPandas' built-in warning appears once per session and scrolls past in a notebook; a ValueError stops the cell. A number in square degrees is not approximately right — it is a different quantity, and downstream code cannot tell.
The message names the fix, which is what turns an error into a useful one.
Example 2: reproject once, correctly, at the boundary
import geopandas as gpd
from pyproj import CRS
NATIONAL = {"GB": 27700, "IE": 2157, "FR": 2154, "DE": 25832,
"NL": 28992, "US": 5070, "AU": 3577}
def load_for_analysis(path, *, crs=None, country=None, columns=None):
"""Read a layer and put it in a projected CRS suitable for measurement."""
gdf = gpd.read_file(path, columns=columns) if columns else gpd.read_file(path)
if gdf.crs is None:
raise ValueError(f"{path} has no CRS — identify it before analysis")
if crs is not None:
target = CRS.from_user_input(crs)
elif country and country in NATIONAL:
target = CRS.from_epsg(NATIONAL[country])
elif gdf.crs.is_projected:
target = gdf.crs # already fine
else:
target = gdf.estimate_utm_crs() # the sensible default
if target.is_geographic:
raise ValueError(f"{target.name} is geographic — pick a projected CRS")
out = gdf.to_crs(target) if gdf.crs != target else gdf
aou = target.area_of_use
inside = True
if aou is not None:
from shapely.geometry import box
inside = box(*aou.bounds).contains(box(*out.to_crs(4326).total_bounds))
print(f"{path}: {gdf.crs.name} → {target.name} "
f"({target.axis_info[0].unit_name})")
if not inside:
print(f" ⚠ the data extends beyond {aou.name} — distortion is not bounded")
return out
parcels = load_for_analysis("parcels.gpkg", country="GB")
print(f"{parcels.geometry.area.sum() / 1e6:,.1f} km²")
parcels.gpkg: WGS 84 → OSGB36 / British National Grid (metre)
587.8 km²
estimate_utm_crs() is the useful default when no national grid applies: it picks the UTM zone containing the data's centroid, which is a reasonable metric CRS anywhere on earth for extents up to a few hundred kilometres.
The area-of-use check is worth the four lines. Put Leeds into the French national grid, as step 4 did, and every coordinate still comes out as plausible-looking metres; only the area-of-use check says the distortion there is outside anything the projection was designed for, and nothing else in the pipeline will notice.
Example 3: quantifying what a projection costs you
import numpy as np
import geopandas as gpd
from pyproj import Geod
def projection_error(gdf, crs, *, samples=300, seed=0):
"""Compare projected areas and lengths against geodesic truth."""
geod = Geod(ellps="WGS84")
g4326 = gdf.to_crs(4326)
rng = np.random.default_rng(seed)
idx = rng.choice(len(g4326), size=min(samples, len(g4326)), replace=False)
sample = g4326.iloc[idx]
projected = sample.to_crs(crs)
true_area = np.array([abs(geod.geometry_area_perimeter(g)[0])
for g in sample.geometry])
true_perim = np.array([abs(geod.geometry_area_perimeter(g)[1])
for g in sample.geometry])
proj_area = projected.geometry.area.to_numpy()
proj_perim = projected.geometry.length.to_numpy()
with np.errstate(divide="ignore", invalid="ignore"):
a_ratio = proj_area / np.where(true_area == 0, np.nan, true_area)
p_ratio = proj_perim / np.where(true_perim == 0, np.nan, true_perim)
name = projected.crs.name
print(f"{name[:46]:<46} (EPSG:{projected.crs.to_epsg()})")
print(f" area median {np.nanmedian(a_ratio):.5f} "
f"worst {100*np.nanmax(np.abs(a_ratio-1)):>6.2f}% off")
print(f" length median {np.nanmedian(p_ratio):.5f} "
f"worst {100*np.nanmax(np.abs(p_ratio-1)):>6.2f}% off")
return a_ratio, p_ratio
wards = gpd.read_file("wards.gpkg")
for crs in [27700, 3857, 32630, 3035]:
projection_error(wards, crs)
OSGB36 / British National Grid (EPSG:27700)
area median 0.99928 worst 0.07% off
length median 0.99964 worst 0.04% off
WGS 84 / Pseudo-Mercator (EPSG:3857)
area median 2.86321 worst 187.53% off
length median 1.69174 worst 69.56% off
WGS 84 / UTM zone 30N (EPSG:32630)
area median 0.99942 worst 0.06% off
length median 0.99971 worst 0.03% off
ETRS89-extended / LAEA Europe (EPSG:3035)
area median 1.00000 worst 0.00% off
length median 0.99987 worst 0.07% off
Four readings worth pulling out. British National Grid and UTM 30N are both accurate to about a tenth of a percent over this extent — either is fine for measurement. EPSG:3035, an equal-area projection, is exact on area to the precision printed and a little worse than the national grid on length, which is precisely the trade an equal-area projection makes. And Web Mercator inflates the Leeds wards' areas by a factor of 2.9: not a rounding error, a wrong answer.
Geod.geometry_area_perimeter computes on the ellipsoid, which is the ground truth. Running this once on your own data turns "which projection should I use" from a judgement into a measurement.
What a coordinate is
The distinction is about what a coordinate is, and everything else follows.
A geographic CRS gives each place two angles: latitude, the angle from the equatorial plane, and longitude, the angle east of a prime meridian. These are positions on a curved surface — an ellipsoid approximating the earth. They locate things exactly and completely, and they are not lengths. There is no arithmetic that turns two angles into an area, because the answer depends on where on the ellipsoid you are.
A projected CRS applies a mathematical transformation from those angles onto a plane, producing coordinates in a linear unit. On that plane, Pythagoras works: the distance between two points is the square root of the sum of squared differences, and area is what you would compute for a polygon on graph paper. Every measurement people want from spatial data assumes this.
The impossibility at the heart of it is that a curved surface cannot be flattened without distortion. This is a theorem — Gauss's Theorema Egregium — not a limitation of any software. So every projection distorts something, and the design of a projected CRS is a decision about what to distort and where to keep the distortion small. National grids do this by covering one country: British National Grid keeps scale error under about 1 part in 2,500 across Britain by accepting that it is useless in Australia.
Web Mercator is the instructive exception: its area of use is the whole world, bought by preserving angles and letting area grow without bound toward the poles, which is how the Leeds wards come out 2.9 times too large (Example 3). Why that is still the right projection for a slippy map is in choosing a map projection for display.
So "which CRS" is two questions. Which kind — angles to locate, a plane to measure — and, for the plane, which region it was built for. The first is this page's; the second is choosing a projected CRS for your study area.
The kind test, zone widths and areas of use
crs.is_geographicandcrs.is_projectedare the reliable test; guessing from coordinate magnitude usually works but not always.- UTM zones are 6° wide. Data spanning more than one zone is distorted at the edges; use a conic projection centred on the data.
crs.area_of_usegives the bounds a projected CRS was designed for; outside them, distortion is unbounded, and the coordinates still look like plausible metres.- Ellipsoidal heights are not in either kind. EPSG:4326 is two-dimensional; a height needs a 3-D CRS such as EPSG:4979, or a separate vertical datum.
Internal links
- Coordinate reference systems explained for Python GIS — the broader concepts
- EPSG codes explained: how to choose the right CRS in Python — reading a CRS definition
- How to choose the right projected CRS for your study area — making the choice
- How to set, assign and convert CRS in GeoPandas — labelling versus converting
- How to measure distance accurately in Python — geodesic versus projected
- GeoPandas "geometry is in a geographic CRS" warning — the warning this explains
- Choosing a map projection for display — the same choice, for maps
- How to calculate area and distance in GeoPandas correctly — the practical measurement guide
- Forest area wrong in degrees — pixel areas on a degree grid
FAQ
Why is my area in the thousandths?
You are measuring in square degrees. A ward of 2.9 km² is about 0.00042 square degrees. Reproject to a projected CRS and the number becomes square metres.
Can I convert square degrees to square metres?
Not by a constant. A degree of longitude is 111 km at the equator and about 66 km at Leeds' 53.8°N, so the conversion depends on latitude. Reproject and measure again.
Which projected CRS should I use?
The national grid for your country if there is one — 27700 for Great Britain, 2154 for France. Otherwise gdf.estimate_utm_crs(), which picks the UTM zone for your data.
Is EPSG:4326 wrong?
No — it is right for storage, interchange and global data. It is only wrong for measurement, because its units are angles.
Should I always reproject before measuring?
For extents up to a few hundred kilometres, yes. Beyond that, or across UTM zones, use pyproj.Geod to compute geodesically on the ellipsoid instead.
Does reprojecting move my data?
The coordinate numbers always change. The actual position moves only if the datum changes too — WGS 84 to OSGB36 shifts points by up to about 120 m, which is a correction, not an error.