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 and projected coordinate systems compared. two kinds of CRS; only one measures anythinggeographicprojectedcoordinates areanglesdistancesunits✕degrees✓metresarea returns✕square degrees✓square metresbuffer(500) means✕500 degrees✓500 metrescovers✓the whole earth!one region wellStore and share geographic; measure and analyse projected spatialworkflow.io
Two kinds of coordinate system. Only one of them measures anything.
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.)

Manchester’s wards drawn twice: on the left straight from longitude and latitude, visibly stretched east–west; on the right in British National Grid, with the correct shape. Manchester’s wards drawn twice: on the left straight from longitude and latitude, visibly stretched east–west; on the right in British National Grid, with the correct shape.
Manchester’s 32 wards (ONS, December 2024) drawn twice. Plotted straight from EPSG:4326, a degree of longitude takes as much room as a degree of latitude, so at 53.5°N the city is stretched east–west by 1 / cos 53.5° ≈ 1.68. In British National Grid a metre is the same length in both directions, and the shape is right.
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

What reprojection changes and what it leaves alone. three things happen when you call to_crscoordinate valuesalways-2.24, 53.48 becomes 383618, 398050measurement unitsalwaysdegrees become metresdistortionalwaysbounded if the CRS suits the areathe actual positiononly on a datum changeup to 120 m for OSGB36set_crs changes none of these — it only relabels spatialworkflow.io
Reprojection changes the numbers and, if the datum changes, the position too.

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

Choosing a projected CRS by the extent the map or the measurement covers. the extent picks the CRS, not the other way roundHow much ground does it cover?one countrythe national grid27700, 2154, 25832a local areathe UTM zoneone zone, from the longitudea continentequal-area, centredLAEA or AlbersWeb Mercator is not on this list — it is for tiles, and its area error grows with latitude spatialworkflow.io
Example 2 reprojects once at the boundary, using whichever of these the extent calls for.

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 ground length of one degree of longitude at each latitude. a degree of latitude is fixed; a degree of longitude is notany latitude111.3 km latlon at 0 deg111.3 kmlon at 30 deg96.4 kmlon at 51.5 deg69.3 kmlon at 70 deg38.1 km0111 kmAt 51.5 N one unit of x is 69 km and one unit of y is 111 km spatialworkflow.io
A degree of latitude is fixed. A degree of longitude is not — which is why the two cannot share a unit.

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.

The four things a CRS has to state before a coordinate pair means a place. a coordinate on its own is two numbersThe datumwhich ellipsoid, placed whereThe unitsdegrees, or metresThe projection, if anynone, for a geographic CRSThe area of useoutside it, error growsTwo systems can both be geographic and still put the same place in different spots spatialworkflow.io
Two geographic CRSs on different datums still disagree about where a place is: the datum is the part people skip.

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_geographic and crs.is_projected are 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_use gives 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.

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.