Choosing an Equal-Area CRS for Forest Area Statistics in Python

Every forest report contains a number in hectares, and that number depends entirely on the projection it was measured in. Compute stand area in Web Mercator at 60° north and you will over-report by a factor of four; compute it in a UTM zone 800 km from the central meridian and you will be off by a fraction of a percent that nobody notices but an auditor. This guide is about picking the projection that makes an area figure defensible, as one decision inside Coordinate Reference Systems for Forestry, part of Ecological GIS Data Foundations in Python.

When to use each projection family

No projection preserves area, distance, shape and direction at once. An area statistic needs an equal-area projection, and the choice within that family is about the shape of the region.

Projection Preserves Best for Typical area error
Albers Equal Area Conic Area Regions wider than tall, mid-latitude — a country, a state < 0.1% with well-placed standard parallels
Lambert Azimuthal Equal Area Area Compact or circular regions, and polar work < 0.5% within ~2000 km of the centre
Cylindrical Equal Area Area Global or wide latitudinal bands Correct in area, badly distorted in shape
UTM Shape (conformal) Local work under ~300 km wide 0.04–0.2% at zone edges
Web Mercator (EPSG:3857) Nothing useful Web basemaps only 78% at 45°N; 300% at 60°N
Geographic (EPSG:4326) Nothing — degrees are not a length Storage and exchange only Meaningless

The practical rule for forestry: use a national or regional equal-area projection for any reported area, and use UTM for local geometry work where distances and shapes matter more than a hectare in ten thousand. Never compute area in Web Mercator, and never compute it from unprojected degrees.

The same 100 hectare stand at four latitudes in two projections On the left, four squares each representing a genuine one hundred hectare stand are drawn as Web Mercator renders them at fifteen, forty-five, sixty and seventy degrees north. They grow steadily larger towards the pole, with the seventy degree square several times the area of the fifteen degree one. On the right, the same four stands in an equal-area projection are drawn identically, because equal area is what that projection preserves. Web Mercator — area inflates with latitude Equal-area projection 15°N 45°N 60°N 70°N 107 ha 178 ha 400 ha 855 ha 15°N 45°N 60°N 70°N 100 ha in every case The stands are identical on the ground. Only the projection differs — and only one of these can be reported.

Minimal reproducible example

Two lines get the area right; the third proves it. geopandas computes planar area in whatever CRS the frame happens to be in, which is exactly why the reprojection has to be explicit.

import geopandas as gpd


def stand_areas_ha(stands_path: str, equal_area_epsg: int = 3035) -> gpd.GeoDataFrame:
    """Stand areas in hectares, computed in a stated equal-area CRS.

    3035 = ETRS89 / LAEA Europe. Use 5070 for CONUS Albers, 3577 for Australian Albers,
    or a custom Albers for a region that none of the standard codes fits well.
    """
    stands = gpd.read_file(stands_path)
    if stands.crs is None:
        raise ValueError("layer has no CRS — refusing to guess")
    ea = stands.to_crs(epsg=equal_area_epsg)
    out = stands.copy()
    out["area_ha"] = ea.area / 10_000.0
    out["area_crs"] = f"EPSG:{equal_area_epsg}"
    return out

Storing area_crs alongside the number is the difference between a figure and a defensible figure. Two reports on the same ownership that disagree by 3% are almost always two projections, and without that column the disagreement takes a day to resolve.

Where no standard code fits — a region straddling zones, or one whose national grid is conformal rather than equal-area — build a custom Albers centred on the data:

import geopandas as gpd
from pyproj import CRS


def local_albers(gdf: gpd.GeoDataFrame) -> CRS:
    """An Albers Equal Area CRS fitted to this layer's own extent."""
    minx, miny, maxx, maxy = gdf.to_crs(4326).total_bounds
    lat0 = (miny + maxy) / 2.0
    lon0 = (minx + maxx) / 2.0
    # Standard parallels at 1/6 and 5/6 of the latitude span minimise distortion.
    span = maxy - miny
    lat1, lat2 = miny + span / 6.0, maxy - span / 6.0
    return CRS.from_proj4(
        f"+proj=aea +lat_1={lat1:.4f} +lat_2={lat2:.4f} "
        f"+lat_0={lat0:.4f} +lon_0={lon0:.4f} "
        "+x_0=0 +y_0=0 +datum=WGS84 +units=m +no_defs"
    )

The one-sixth rule for standard parallels is the standard heuristic and holds distortion under 0.1% across a region a few hundred kilometres tall. Record the resulting PROJ string with the output; a custom CRS that exists only in a script is not reproducible.

Parameter reference

Choice Option When Note
EPSG:3035 ETRS89 LAEA Europe European work The official European statistical grid
EPSG:5070 NAD83 Albers CONUS Continental United States The standard for US federal area reporting
EPSG:3577 GDA94 Albers Australia Australia National equal-area standard
EPSG:6933 WGS84 NSIDC EASE-Grid 2.0 Global gridded products Cylindrical equal area; heavy shape distortion
Custom Albers +proj=aea A region no standard code fits Set lat_1/lat_2 at 1/6 and 5/6 of the latitude span
Custom LAEA +proj=laea Compact or high-latitude region Centre on the study area
UTM zone +proj=utm Local geometry, buffers, distances Not equal-area; acceptable under ~300 km

Expected output and verification

The check that settles arguments is to compute the same area three ways and compare. Geodesic area — computed on the ellipsoid itself, with no projection — is the reference truth:

import geopandas as gpd
from pyproj import Geod


def compare_area_crs(stands_path: str, candidates: dict[str, int]) -> list[dict]:
    """Compare projected areas against the geodesic (ellipsoidal) truth."""
    stands = gpd.read_file(stands_path)
    geod = Geod(ellps="WGS84")
    truth_ha = sum(
        abs(geod.geometry_area_perimeter(geom)[0]) for geom in stands.to_crs(4326).geometry
    ) / 10_000.0

    rows = [{"crs": "geodesic (truth)", "area_ha": round(truth_ha, 2), "error_pct": 0.0}]
    for name, epsg in candidates.items():
        ha = stands.to_crs(epsg=epsg).area.sum() / 10_000.0
        rows.append({
            "crs": f"{name} (EPSG:{epsg})",
            "area_ha": round(ha, 2),
            "error_pct": round(100.0 * (ha - truth_ha) / truth_ha, 3),
        })
    return rows


for row in compare_area_crs("stands.gpkg", {
    "LAEA Europe": 3035, "UTM 33N": 32633, "Web Mercator": 3857,
}):
    print(row)

Expect the equal-area result within about 0.1% of geodesic, UTM within a few tenths of a percent, and Web Mercator wrong by tens of percent — the exact figure depending on latitude. Turn that into an assertion so a projection mistake fails the pipeline rather than the audit:

def assert_area_crs_sane(gdf, epsg: int, tolerance_pct: float = 0.5) -> None:
    """Refuse to report areas from a CRS that distorts them beyond tolerance."""
    geod = Geod(ellps="WGS84")
    truth = sum(abs(geod.geometry_area_perimeter(g)[0])
                for g in gdf.to_crs(4326).geometry)
    got = gdf.to_crs(epsg=epsg).area.sum()
    err = 100.0 * (got - truth) / truth
    assert abs(err) <= tolerance_pct, (
        f"EPSG:{epsg} distorts area by {err:.2f}% over this extent — "
        "use an equal-area CRS fitted to the region")
Area error against distance from the projection centre Three curves plot absolute area error against distance from the projection centre out to eight hundred kilometres. The equal-area conic stays essentially flat just above zero. The UTM curve rises gently and passes a quarter of a percent near the zone edge at about three hundred kilometres. The Web Mercator curve rises steeply and leaves the top of the chart before two hundred kilometres. 0 0.25 0.5 0.75 0 200 400 600 800 distance from projection centre (km) |area error| (%) equal-area conic UTM Web Mercator (off scale) UTM zone edge UTM is fine for local geometry and wrong for a regional hectare total; the conic is flat wherever you go. Which projection property each task actually needs A table mapping five common tasks to the projection property each requires. Reporting hectares needs equal area. Buffering and distance need a local projection with minimal scale distortion. Slope and terrain derivatives need a metre-based projected frame. Overlay and zonal statistics need only that all layers share one frame. Web display needs Web Mercator and no analytical property at all. report hectares buffer, measure distance slope, aspect, ruggedness overlay and zonal summary web display equal-area projection local, low scale distortion any metre-based projection only: all layers in one frame Web Mercator, nothing else One project routinely needs three of these at once — which is why the frame belongs to the task, not the file.

Common pitfalls

  • Computing area on an unprojected frame. gdf.area on EPSG:4326 returns square degrees. It does not warn, and the number looks like a plausible small figure.
  • Reporting from Web Mercator because that is what the basemap used. Display CRS and analysis CRS are independent choices; keep the map in 3857 and the statistics in an equal-area frame.
  • Using one UTM zone across a region that spans three. Stands in the far zones carry the largest error and they are exactly the ones nobody checks.
  • Mixing datums silently. Reprojecting NAD27 data as though it were NAD83 shifts geometry by up to 200 m. Area survives, position does not — and the overlay against a raster then samples the wrong pixels.
  • Not recording the CRS with the number. Every area figure should travel with the EPSG code or PROJ string that produced it.

Frequently Asked Questions

Should I use geodesic area instead of any projection?

For a single total, yes — it is the truth and pyproj.Geod computes it directly. Projections still matter because most workflows do more than sum areas: overlays, buffers, zonal statistics and rasterisation all need a planar frame, and the frame should be the one whose distortion least affects the quantity you care about.

Does the choice matter for small stands?

Less than people fear for the individual figure, and more than they expect for the total. A 20 ha stand in Web Mercator at 60° north reports as roughly 80 ha — the relative error is the same at every size, so summing thousands of small stands compounds it rather than averaging it away.

What about area of a raster class rather than polygons?

Same rule, applied to pixels: a pixel’s ground area is only constant in an equal-area projection. Counting pixels of a forest class in Web Mercator and multiplying by a nominal cell size gives an answer that is wrong by the same latitude-dependent factor. Reproject the classification to an equal-area grid, or weight each pixel by its true ground area.

Up: Coordinate Reference Systems for Forestry · Ecological GIS Data Foundations in Python