Every spatial system converges on one storage frame, whether or not anyone chose it. Choosing deliberately takes an afternoon and settles a decade of arguments about why two distances disagree. This guide works through the decision and the rule that goes with it — one frame for storage, a projection selected per measurement — as the first step of coordinate reference system normalization.
When to Use This Approach
Make the choice before the first geometry is stored, and revisit it only when the system’s geographic scope changes fundamentally. Retrofitting a different canonical frame onto a populated corpus is a migration, not a configuration change.
| Situation | Storage frame | Reason |
|---|---|---|
| Multi-country or growing scope | Global geographic | Survives expansion without migration |
| Single country, metric-heavy work | National projected, or global geographic | Either defensible; projected saves transforms |
| Data arrives in one national grid only | That grid, if scope is fixed | No transform on the hot path |
| Web mapping is the primary consumer | Global geographic, transform at render | Never store the display projection |
The last row is the one that causes the most damage. Storing geometry in a web mapping projection is convenient for one consumer and wrong for every measurement: that projection’s distortion grows with latitude and areas computed in it are meaningless.
Implementation
The choice is expressed as configuration plus two functions: one that declares the canonical frame, and one that selects a measurement projection for a given geometry and purpose.
import logging
from dataclasses import dataclass
from typing import Literal
from pyproj import CRS, Transformer
from pyproj.exceptions import CRSError
log = logging.getLogger("canonical_crs")
CANONICAL_EPSG = 4326 # storage: global, unambiguous, portable
EQUAL_AREA_EPSG = 6933 # global equal-area, for area measurement
Purpose = Literal["area", "distance", "display"]
@dataclass(frozen=True)
class Projection:
crs: CRS
why: str # recorded with any number it produces
def canonical() -> CRS:
"""The one frame everything is stored and compared in."""
try:
return CRS.from_epsg(CANONICAL_EPSG)
except CRSError as exc: # a broken installation, not a data problem
raise RuntimeError(f"canonical frame EPSG:{CANONICAL_EPSG} unavailable: {exc}") from exc
def measurement_projection(geom, purpose: Purpose) -> Projection:
"""Select a metric projection appropriate to this geometry and this question."""
if purpose == "area":
return Projection(CRS.from_epsg(EQUAL_AREA_EPSG),
f"equal-area EPSG:{EQUAL_AREA_EPSG}")
if purpose == "distance":
lon, lat = geom.centroid.x, geom.centroid.y
proj = CRS.from_proj4(
f"+proj=aeqd +lat_0={lat:.6f} +lon_0={lon:.6f} "
"+datum=WGS84 +units=m +no_defs")
return Projection(proj, f"azimuthal equidistant centred on {lat:.4f}, {lon:.4f}")
raise ValueError(f"no measurement projection for purpose {purpose!r}")
def to_projection(geom, projection: Projection):
"""Move geometry from the canonical frame into a measurement projection."""
tf = Transformer.from_crs(canonical(), projection.crs, always_xy=True)
from shapely.ops import transform
try:
return transform(tf.transform, geom)
except Exception as exc:
log.warning("projection to %s failed: %s", projection.why, exc)
raise
Two decisions in that code are worth defending. The measurement projection is built per geometry for distance work, because a projection centred on the thing being measured has negligible distortion nearby and any fixed alternative does not. And the projection carries a why string, because a number without the projection that produced it cannot be reproduced or compared to another number produced differently.
Raising on an unknown purpose rather than falling back to the canonical frame is equally deliberate. A silent fallback would compute areas in square degrees, which is the single error this whole arrangement exists to prevent.
Validation & Testing
from shapely.geometry import box
def test_area_is_measured_in_an_equal_area_projection():
# A degree-sized box at two latitudes must not report the same area.
south = box(-3.0, 10.0, -2.0, 11.0)
north = box(-3.0, 56.0, -2.0, 57.0)
a_south = to_projection(south, measurement_projection(south, "area")).area
a_north = to_projection(north, measurement_projection(north, "area")).area
assert a_south > a_north * 1.5, "equal-area projection is not being applied"
def test_distance_projection_is_centred_on_the_geometry():
subject = box(-3.2, 55.9, -3.1, 56.0)
proj = measurement_projection(subject, "distance")
assert "aeqd" in proj.crs.to_proj4()
assert "55.9" in proj.why or "55.95" in proj.why
def test_unknown_purpose_raises_rather_than_defaulting():
try:
measurement_projection(box(0, 0, 1, 1), "volume")
except ValueError:
return
raise AssertionError("an unknown purpose must not silently use the storage frame")
The first test is a property rather than a fixture check: a degree box near the equator genuinely covers more ground than one near the pole, and any implementation that reports them as equal has skipped the projection. It needs no reference values and does not break when a library updates its constants.
Run these against the real configuration rather than a test double. The value of this whole arrangement is that one module decides the frames, and a test that stubs that module is testing something else.
Gotchas & Edge Cases
Storing the display projection. Convenient for one consumer and corrupting for every measurement, because that projection’s scale factor varies with latitude. Transform at render, and never write the result back.
A national grid chosen before the scope was known. The system expands into a neighbouring country and half the data is now outside the grid’s area of use, where its accuracy degrades quietly. Prefer a global frame unless the scope is genuinely fixed by something other than current ambition.
Per-geometry projections used for comparison. Two areas measured in two different locally centred projections are not strictly comparable. Use the global equal-area projection for areas, which is the same everywhere, and reserve locally centred projections for distances, where the geometry being measured is the centre.
A canonical frame declared but not enforced. Configuration says one thing and a legacy import path writes another. The column type and check constraint described in the parent topic are what make the declaration structural rather than aspirational.
Assuming the axis order of the canonical frame. The global geographic frame is defined latitude-first in its authoritative definition and is handled longitude-first by most software. Pass always_xy=True consistently, and test it, or half the pipeline will disagree with the other half about which number is which.
Run the property tests on real geometry from the corpus rather than on synthetic boxes wherever you can. Synthetic shapes are axis-aligned and centred conveniently, which is exactly the configuration in which a projection error is smallest; a real parcel at a real latitude exercises the same code with the distortions that actually occur.
It is also worth asserting the axis-order convention explicitly in this test file rather than trusting it. Passing always_xy=True everywhere is the correct habit, and a single call site that omits it produces geometry that is silently transposed — a failure that looks like a data problem and is a configuration one.
Frequently Asked Questions
Is a projected canonical frame ever the better choice?
Yes, when the system genuinely will not leave one region and metric operations dominate every request. Storing in a national grid removes a transform from the hot path and makes distances directly computable, which is a real saving on a high-volume workload. The cost is that expansion becomes a migration, and expansion is easier to predict in hindsight than in advance — so take this route when the constraint is external, such as a regulatory boundary, rather than when it merely seems unlikely to change.
Which equal-area projection should be used globally?
Any of the standard global equal-area options works, and the choice matters far less than using one consistently. What matters is that areas across the corpus are computed in the same projection, so that two figures can be compared, and that the projection is equal-area rather than merely metric — a metric projection that is not equal-area will happily report square metres that are wrong by tens of per cent at the edges of its zone.
How should the choice be recorded?
In one module, as a constant with a comment explaining the reasoning, and in the database schema as a typed geometry column. Both matter: the constant is what application code reads, the column type is what stops a different code path from writing something else. A decision recorded only in a design document is one that will be contradicted by the second person to write an import script.
Does the canonical frame apply to raster data too?
Not usefully. Rasters carry their own grids, and reprojecting them to match a vector canonical frame resamples every pixel, which is a loss and — for categorical data — a corruption. The rule for rasters is the inverse: bring the vector to the raster's frame for each operation, as described in vector-raster hybrid processing, and store rasters as they arrive.
What happens to the choice when a new region is added?
With a global geographic storage frame, nothing — that is the property being bought. With a projected national frame it is a migration: every stored geometry must be transformed, every stored measurement recomputed, and every index rebuilt. Knowing that in advance is what makes the original choice a decision rather than a default, and it is worth writing the migration cost down next to the constant so the trade is visible to whoever inherits it.