Projected vs Geographic CRS: What Actually Changes When You Reproject

Problem statement

The warning appears and everyone ignores it:

gdf.geometry.area
UserWarning: 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.

It is not being pedantic. Here is what those numbers mean:

gdf_wgs84 = gpd.read_file("wards.gpkg")           # EPSG:4326
print(gdf_wgs84.geometry.area.iloc[0])            # 0.00042117  ← square degrees

gdf_bng = gdf_wgs84.to_crs(27700)                 # EPSG:27700
print(gdf_bng.geometry.area.iloc[0])              # 2,884,102.4  ← square metres

A square degree is not a unit of area. It is roughly 12,300 kmΒ² at the equator, roughly 7,700 kmΒ² at 51.5Β°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

Grid comparing geographic and projected CRS across units, measurement, distortion and typical use.
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.

Step-by-step solution

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, 51.5, 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
 51.5Β°   1Β° lat = 111.32 km   1Β° lon =  69.28 km   ratio 1.61
   60Β°   1Β° lat = 111.32 km   1Β° lon =  55.66 km   ratio 2.00
   70Β°   1Β° lat = 111.32 km   1Β° lon =  38.08 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 London's latitude one unit of x is 69 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 61% before any other consideration.

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))                              # 1.0        ← degrees
print(gpd.GeoSeries([a], crs=4326).to_crs(27700).distance(
      gpd.GeoSeries([b], crs=4326).to_crs(27700)).iloc[0])   # 67,027.8 metres

3. Know what reprojection actually does

Stack showing what reprojection changes and what it preserves.
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. (-2.2426, 53.4808) becomes (383618.5, 398050.4). 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.

And one thing that is not reprojection:

gdf.set_crs(27700, allow_override=True)   # relabels β€” coordinates unchanged
gdf.to_crs(27700)                         # transforms β€” coordinates change

set_crs states what the existing numbers mean. to_crs converts them into a different system. Mixing these up puts data confidently in the wrong place β€” see set_crs vs to_crs.

If the datum also changes, the position moves. Going from WGS 84 to OSGB36 is not only a change of units and origin; the two systems disagree about where the ellipsoid sits relative to the earth, by up to about 120 m in Britain. That correction is applied by a transformation grid, and it needs the grid file to be accurate β€” see reprojecting between datums correctly.

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, ma…
 32630  WGS 84 / UTM zone 30N                  Between 6Β°W and 0Β°W, northern hem…
  3857  WGS 84 / Pseudo-Mercator               World between 85.06Β°S and 85.06Β°N
  5070  NAD83 / Conus Albers                   United States (USA) - CONUS onsho…
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))

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(-2.2426, 53.4808, 2.3522, 48.8566)   # Manchester β†’ Paris
print(f"{metres / 1000:,.1f} km")                             # 623.3 km

That is exact on the ellipsoid. Projecting both points into a single UTM zone and measuring Euclidean distance over 623 km introduces error, because they are 4.6 degrees of longitude apart and no zone covers both well. 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.

Code examples

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.
1,276.4 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)
8,412.9 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. Using EPSG:27700 for data in Ireland produces plausible-looking metres that are wrong by hundreds of metres, and nothing else in the pipeline will notice.

Reproject once, at the boundary. Calling to_crs inside a loop is one of the most expensive things you can do β€” it constructs a new Shapely object per geometry every time.

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.99961   worst   0.08% off
  length median 0.99980   worst   0.04% off
WGS 84 / Pseudo-Mercator                       (EPSG:3857)
  area   median 2.58840   worst 161.24% off
  length median 1.60886   worst  61.20% off
WGS 84 / UTM zone 30N                          (EPSG:32630)
  area   median 0.99984   worst   0.11% off
  length median 0.99992   worst   0.06% off
ETRS89-extended / LAEA Europe                  (EPSG:3035)
  area   median 1.00000   worst   0.02% off
  length median 1.00104   worst   0.21% 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 essentially exact on area and slightly worse on length, which is precisely the trade an equal-area projection makes. And Web Mercator inflates areas by a factor of 2.6 at these latitudes: 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.

Explanation

Bars showing the ground length of one degree of longitude at each latitude against the constant degree of latitude.
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 worth understanding as the counter-example. Its area of use is the whole world, and it achieves that by being conformal β€” preserving local angles β€” while letting area inflate without bound toward the poles. It is the right choice for a slippy map, where a road junction should look like a right angle at every zoom, and a poor one for measurement, where a 2.6Γ— area error at British latitudes is not a subtlety. That trade-off is explored in choosing a map projection for display.

The datum is the part people miss. Two coordinate systems can both be geographic and still disagree about where a place is, because they use different ellipsoids positioned differently relative to the earth. WGS 84 and OSGB36 differ by up to about 120 m in Britain. So a transformation between them is not a change of units β€” it physically moves the point, and doing it accurately requires a grid file describing the local difference. EPSG:4326 and EPSG:27700 differ in both respects at once, which is why that particular transformation is the one where accuracy questions arise.

The operational conclusion is a two-line rule. Store and share in a geographic CRS, because it is universal, unambiguous and covers the world. Measure and analyse in a projected CRS chosen for the area, because that is the only place where distance, area and buffers mean anything. Reproject once at the boundary between those two worlds, rather than repeatedly inside a pipeline β€” and for distances beyond a few hundred kilometres, skip projection entirely and compute geodesically on the ellipsoid.

Edge cases or notes

  • A square degree is not a unit of area. It varies from ~12,300 kmΒ² at the equator to zero at the poles.
  • crs.is_geographic and crs.is_projected are the reliable test; guessing from coordinate magnitude usually works but not always.
  • estimate_utm_crs() picks the UTM zone for the data's centroid β€” a good default metric CRS anywhere.
  • UTM zones are 6Β° wide. Data spanning more than one zone is distorted at the edges; use a conic projection.
  • EPSG:3857 is projected but a poor measuring frame β€” 2.6Γ— area error at 51.5Β°N.
  • Geodesic beats projected for long distances. pyproj.Geod computes on the ellipsoid with no projection error.
  • Datum changes move points, up to ~120 m for WGS 84 ↔ OSGB36. That needs a grid file, not just a formula.
  • crs.area_of_use gives the bounds a projected CRS was designed for; outside them, distortion is unbounded.
  • set_crs labels, to_crs converts. Confusing them puts data confidently in the wrong place.
  • .to_crs() is expensive β€” it builds a new geometry per feature. Do it once, not in a loop.

FAQ

What is the difference in one sentence?

A geographic CRS gives angles on a curved surface, so measurements are meaningless; a projected CRS gives distances on a plane, so they are not.

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 69 km at 51.5Β°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.