Choosing Between EPSG:4326 and a Projected CRS
Store in whatever the data will mostly be used in; measure in a projected system chosen for the extent; interchange in EPSG:4326. Those are three separate decisions, and treating them as one is why so many containers end up computing areas in degrees. The short version: 4326 is the right answer for handing a file to someone else and the wrong answer for anything with units.
This page belongs to the Datum Transformations & Projection Accuracy guide.
Why This Matters
EPSG:4326 is a geographic reference system: coordinates are angles on an ellipsoid, not positions on a plane. Every function that assumes a plane — length, area, buffer, distance — therefore produces a number whose unit is “degrees”, which is not a unit of length and varies with latitude in one axis and not the other.
Nothing raises. ST_Area returns a float, ST_Buffer returns a polygon, ST_Distance returns a number that is even roughly monotonic with real distance. The result is a pipeline that computes plausible nonsense and a report that is wrong by a factor which changes as you move north.
Prerequisites
- A clear statement of what the container is for — analysis, distribution, or both
- The geographic extent the data covers
pyproj3.4+ if you want the library to suggest a projected system- Familiarity with the registry, from How to Add a Custom CRS to gpkg_spatial_ref_sys
Primary Method
# Ask PROJ which projected systems suit an extent, ranked by area of use
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info, query_crs_info
def suggest_projected(min_lon, min_lat, max_lon, max_lat):
aoi = AreaOfInterest(min_lon, min_lat, max_lon, max_lat)
utm = query_utm_crs_info(datum_name="WGS 84", area_of_interest=aoi)
for info in utm:
print(f"UTM EPSG:{info.code:<6} {info.name}")
projected = query_crs_info(
pj_types="PROJECTED_CRS", area_of_interest=aoi, contains=True
)
for info in projected[:5]:
print(f"national EPSG:{info.code:<6} {info.name}")
For most work the answer is either the national grid where one exists — it is what the surrounding data uses and what local consumers expect — or the UTM zone covering the extent, which is a reasonable default anywhere and stays accurate over a few hundred kilometres.
Step-by-Step Walkthrough
1. Demonstrate the problem before choosing
The fastest way to settle an argument about this is to compute the same area both ways.
from shapely.geometry import Polygon
from pyproj import Transformer
# A square roughly 1 km on a side, at 51°N
poly_4326 = Polygon([
(-0.10, 51.50), (-0.10, 51.509), (-0.086, 51.509), (-0.086, 51.50), (-0.10, 51.50)
])
print(f"area in 4326: {poly_4326.area:.10f}") # square degrees — meaningless
to_27700 = Transformer.from_crs("EPSG:4326", "EPSG:27700", always_xy=True)
from shapely.ops import transform
poly_27700 = transform(to_27700.transform, poly_4326)
print(f"area in 27700: {poly_27700.area:,.1f} m²") # ~1,000,000
The first number is not “the area in different units” — it is a quantity with no physical interpretation, because a degree of longitude and a degree of latitude are different lengths and the ratio changes as you move.
2. Decide storage from the dominant use
If the container is read mostly by a field application that renders a Web Mercator basemap, storing in EPSG:3857 removes a per-feature transform on every frame. If it is read mostly by an analysis pipeline computing areas, store in the projected system that pipeline measures in. If it is read mostly by external parties, store in 4326.
The decision is about which transformation you are willing to pay for repeatedly, because you will pay for the others once at the boundary.
3. Never measure in a geographic system
Where measurement must happen against 4326-stored data, two options are correct and one is not.
# Correct: transform, then measure
projected = transform(to_27700.transform, geom)
area_m2 = projected.area
# Also correct: measure geodesically, no projection involved
from pyproj import Geod
geod = Geod(ellps="WGS84")
area_m2_geodesic, perimeter_m = geod.geometry_area_perimeter(geom)
area_m2_geodesic = abs(area_m2_geodesic)
# Wrong: geom.area on a 4326 geometry
The geodesic route is the more accurate of the two over large extents, because it does not inherit a projection’s distortion. Over a survey-sized area the two agree closely, and the projected route is faster.
4. Keep the spatial index in the storage system
A query whose geometry is in a different reference system than the layer cannot use the R-tree, because the index holds bounding boxes in the stored system. Transform the query geometry once, before the query, rather than transforming every row inside it.
# Transform the search window into the layer's system, once
window_4326 = box(-0.11, 51.49, -0.08, 51.52)
window = transform(to_27700.transform, window_4326)
minx, miny, maxx, maxy = window.bounds
rows = conn.execute("""
SELECT p.fid FROM parcels p
JOIN rtree_parcels_geom r ON r.id = p.fid
WHERE r.maxx >= ? AND r.minx <= ? AND r.maxy >= ? AND r.miny <= ?
""", (minx, maxx, miny, maxy)).fetchall()
5. Convert at the boundary, and record that you did
Exporting to 4326 for handover is a transformation like any other, which means it takes a route and deserves the same provenance record the parent guide describes. A recipient who knows the operation used can reproduce your coordinates; one who knows only the target system cannot.
Verification
-- Every layer's declared system, in one place
SELECT c.table_name, c.srs_id, s.srs_name, s.organization
FROM gpkg_contents c
JOIN gpkg_spatial_ref_sys s ON s.srs_id = c.srs_id
WHERE c.data_type = 'features';
# An area computed in the storage system must match a geodesic area
from pyproj import Geod
geod = Geod(ellps="WGS84")
planar = projected_geom.area
geodesic = abs(geod.geometry_area_perimeter(geom_4326)[0])
assert abs(planar - geodesic) / geodesic < 0.005, (
f"projected area differs from geodesic by "
f"{abs(planar - geodesic) / geodesic:.2%} — wrong projection for this extent"
)
That assertion is a genuinely useful gate. A projected system appropriate to the extent agrees with the geodesic figure to a fraction of a per cent; one chosen badly — a UTM zone two zones away, a national grid used outside its area of use — diverges enough to fail immediately.
Alternative Approaches or Edge Cases
Web Mercator (EPSG:3857) for storage. It is projected, so planar functions return numbers with units — but the units are badly distorted away from the equator, and area error reaches a factor of two at high latitude. It is the right storage system when the dominant use is rendering web tiles and the wrong one whenever anything is measured.
Data spanning several UTM zones. No single UTM zone is appropriate, and forcing one produces increasing distortion at the edges. Options are a national grid with a wider area of use, an equal-area projection if area is what matters, or measuring geodesically and storing in 4326.
Mixed-system containers. A GeoPackage may hold layers in different systems, which is legitimate and occasionally right — a basemap in Web Mercator beside survey data in a national grid. The cost lands on any query that joins across them, and the fix is to transform one side once rather than to redesign the container.
Troubleshooting
Areas are out by a factor of roughly 10,000
Cause: Area computed in square degrees and then treated as square metres, or the reverse. The ratio at mid-latitudes is close to that magnitude. Fix: Check what system the geometry was in when .area was called — the number is not convertible after the fact, because the conversion factor varies across the extent.
Distances are right east-to-west and wrong north-to-south
Cause: A degree of latitude is nearly constant and a degree of longitude shrinks with latitude, so a planar distance in 4326 is wrong differently in each axis. Fix: Any of the correct routes above. This asymmetry is a reliable fingerprint for geographic coordinates being measured as planar.
A projected layer renders in the wrong place on a web map
Cause: The map library assumes 4326 or 3857 and the layer is in something else. Fix: Transform on export for web consumption, or store in 3857 if rendering is the dominant use — and keep the measurement path in a suitable projected system regardless.
Frequently Asked Questions
Is EPSG:4326 ever right for storage?
Yes — when the container’s dominant use is handing it to someone else. An interchange artefact stored in 4326 needs no reference-system definition beyond the mandatory row, opens correctly in every tool, and asks nothing of the recipient. The rule that matters is not “never store 4326” but “never measure in it”, and those are separable.
How much accuracy does UTM lose at the edge of a zone?
A UTM zone is six degrees wide and its scale factor varies from about 0.9996 at the central meridian to about 1.0010 at the zone edge, so distances are off by roughly one part in a thousand at worst — a metre per kilometre. That is fine for most survey work and not fine for engineering; where it matters, a national grid designed for the area, or a local system with a suitable central meridian, is the answer.
Should the tile pyramid and the feature layers share a system?
It saves the client a transform on every frame, which on a field device is measurable. The pyramid usually commits to Web Mercator because that is what mobile map libraries expect, so matching means storing features in 3857 too — acceptable when nothing is measured from them, and a poor trade when something is. Storing features in the measurement system and letting the client transform is the more common compromise.
Related
- Datum Transformations & Projection Accuracy — parent guide: routes, accuracy and reproducibility
- How to Add a Custom CRS to gpkg_spatial_ref_sys — registering the system you decided to store in
- Raster Tiles & Coverage in GeoPackage — why a pyramid commits to one system for the whole container
- How to Create a Spatial Index in SQLite with Python — why the index only helps a query in the stored system