EPSG Code vs PROJ String vs WKT: Which CRS Format to Use
A coordinate reference system can be written down three ways — as an authority code, as a +proj= string, or as WKT — and only two of them survive the trip intact. This guide is for anyone who has to decide what goes in a file header, a database column or a deployment config while working through Coordinate Systems with PyProj in Mastering Core Geospatial Python Libraries. The three formats are not stylistic preferences: one of them silently deletes the datum, and a CRS with no datum quietly relocates your data by tens or hundreds of metres.
Why This Approach / What Goes Wrong
A CRS is more than a projection formula. It is a projection plus a datum (which physical realization of the Earth's shape the coordinates are pinned to), plus an ellipsoid, plus a coordinate system with declared axis order and units, plus an area of use that tells PROJ which transformation path is legal. The three notations carry different subsets of that.
An EPSG code — EPSG:27700 — is not a definition at all. It is a ten-character lookup key into the authority registry that PROJ ships as proj.db. Everything the CRS knows lives in that database, keyed by the code and versioned by the EPSG dataset release. Hand the code to any PROJ 6+ toolchain and you get the identical, fully-specified CRS; hand it to a system without the registry, or with a decade-old copy of it, and you get nothing or something subtly different.
WKT is the opposite trade: the whole definition, spelled out in text. WKT2:2019 (ISO 19162) names the datum, the ellipsoid parameters, the prime meridian, each axis with its direction and unit, the usage scope, the bounding box of validity, and — when the CRS came from a registry — the authority ID as well. For EPSG:27700 that is 1,163 characters instead of 10, and every one of them earns its place. Legacy WKT1 (the dialect in a shapefile .prj) keeps the datum name so PROJ can still resolve the right transformation, but drops the usage and area-of-use blocks and cannot express a datum ensemble or a dynamic reference frame.
A PROJ string — +proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 ... — is the odd one out. It is an argument list for the PROJ engine, not a description of a CRS, and it only has slots for the maths. There is no field for "OSGB 1936" and no field for "valid in Great Britain". PROJ 6 formalized this: CRS.to_proj4() now raises a UserWarning telling you that you are about to lose information, and the PROJ FAQ recommends against the format for storage. The same release deprecated the +init=epsg: syntax, which never was a CRS definition either — it was a directive telling PROJ 4 to go read an init file, and it carried PROJ 4's old always-x-y axis assumptions along with it.
The practical consequence is that a PROJ string round trip is lossy in a way that never raises. Ask pyproj for the PROJ string of EPSG:27700 and read it back, and you get a CRS named unknown whose datum is Unknown based on Airy 1830 ellipsoid. PROJ can still project with it — the maths is all there — but it can no longer connect that datum to WGS 84 through the published OSGB36 transformation, so it falls back to a ballpark offset: pretend the two datums coincide. For Great Britain that pretence is worth about 125 metres.
Prerequisites
pyproj>=3.6— theCRSclass,to_epsg(min_confidence=...),from_user_input, PROJJSON support and the+init=FutureWarningPROJ>=9.0— bundled inside the pyproj wheel; supplies theproj.dbauthority database every EPSG code resolves againstpython>=3.10- (optional)
geopandas>=1.0— if you are stamping the CRS onto layers rather than raw coordinate arrays
python -m pip install "pyproj>=3.6,<4" "geopandas>=1.0"
Confirm the database behind the codes before trusting any of them — a stale proj.db shadowed by a leftover PROJ_LIB is the usual reason a valid code resolves to the wrong definition:
import pyproj
print(pyproj.__version__, pyproj.proj_version_str) # 3.7.2 9.7.1
print(pyproj.datadir.get_data_dir()) # …/site-packages/pyproj/proj_dir/share/proj
Step-by-Step Implementation
The worked example uses EPSG:27700, OSGB36 / British National Grid, because its datum sits roughly 120 m from WGS 84 — far enough that a lost datum is unmistakable rather than a rounding artefact.
1. Build the same CRS three ways and compare the objects. pyproj.CRS implements equality on the full definition, not on the string you happened to pass.
import warnings
from pyproj import CRS
by_code = CRS.from_epsg(27700)
by_wkt = CRS.from_wkt(by_code.to_wkt(version="WKT2_2019"))
with warnings.catch_warnings():
warnings.simplefilter("ignore") # to_proj4() warns that it is lossy — that is the point
by_proj = CRS.from_proj4(by_code.to_proj4())
print(by_code == by_wkt) # True — WKT2 is a faithful serialization
print(by_code == by_proj) # False — the PROJ string is not
2. Inspect exactly what the PROJ string dropped. Every attribute below is populated on by_code and hollowed out on by_proj.
print(by_code.datum.name) # Ordnance Survey of Great Britain 1936
print(by_proj.name) # unknown
print(by_proj.datum.name) # Unknown based on Airy 1830 ellipsoid
print(by_proj.area_of_use) # None
3. Do not be reassured by to_epsg(). It performs a fuzzy match of the definition against the registry, so the round-tripped CRS still reports code 27700 — the projection parameters and ellipsoid are enough to identify the entry. Raising the confidence threshold to 100 demands a byte-exact match and exposes the truth.
print(by_proj.to_epsg()) # 27700 <- fuzzy match, NOT a restored datum
print(by_proj.to_epsg(min_confidence=100)) # None <- the definition is not the registry's
print(by_code.to_epsg(min_confidence=100)) # 27700
4. Measure the damage in metres. Transform one London point into each version of the CRS. Both calls set always_xy=True so axis order plays no part in the difference — the gap is purely the missing datum, the same class of error diagnosed in fixing PyProj CRS transformation errors.
import math
from pyproj import Transformer
london = (-0.1276, 51.5072) # lon, lat — WGS 84
intact = Transformer.from_crs("EPSG:4326", by_code, always_xy=True)
lossy = Transformer.from_crs("EPSG:4326", by_proj, always_xy=True)
x1, y1 = intact.transform(*london)
x2, y2 = lossy.transform(*london)
print(f"{x1:.2f}, {y1:.2f}") # 530043.19, 180358.21
print(f"{x2:.2f}, {y2:.2f}") # 529930.27, 180412.11
print(f"offset: {math.hypot(x2 - x1, y2 - y1):.2f} m") # offset: 125.13 m
5. Turn the silence into an exception with allow_ballpark=False. PROJ only reached a result for the lossy CRS by assuming the two datums are the same. Forbid that assumption and the transformer refuses to build at all.
from pyproj.exceptions import ProjError
try:
Transformer.from_crs("EPSG:4326", by_proj, always_xy=True, allow_ballpark=False)
except ProjError as exc:
print("refused:", exc) # refused: Error creating Transformer from CRS.
# The intact CRS builds a real, published operation with no ballpark step
Transformer.from_crs("EPSG:4326", by_code, always_xy=True, allow_ballpark=False)
6. Retire +init=epsg: for good. The syntax still parses in PROJ 9 but emits a FutureWarning, and it drags PROJ 4's implicit longitude-first axis convention into a world where EPSG:4326 declares latitude first. Promote the warning to an error in CI so no new occurrence reaches production.
import warnings
from pyproj import CRS
warnings.simplefilter("error", FutureWarning)
try:
CRS.from_string("+init=epsg:27700")
except FutureWarning as exc:
print("blocked:", str(exc)[:48])
# blocked: '+init=<authority>:<code>' syntax is deprecated.
crs = CRS.from_user_input("EPSG:27700") # the replacement — no warning, no axis surprise
EPSG: string.7. Normalize whatever the outside world hands you. CRS.from_user_input accepts an integer, an EPSG: string, WKT of any dialect, a PROJ string or a PROJJSON dict. Canonicalize once at the boundary and refuse definitions whose datum has already been thrown away.
from pyproj import CRS
def canonical_crs(value, *, strict: bool = True) -> CRS:
"""Accept any CRS notation; return one authoritative CRS object."""
crs = CRS.from_user_input(value)
code = crs.to_epsg(min_confidence=100)
if code is not None:
return CRS.from_epsg(code) # exact registry match — use the authority's copy
if strict and (crs.datum is None or "Unknown" in crs.datum.name):
raise ValueError(f"lossy CRS definition — datum is {crs.datum}")
return crs # a genuine custom CRS — keep the full text
print(canonical_crs(27700).to_authority()) # ('EPSG', '27700')
print(canonical_crs("EPSG:27700").datum.name) # Ordnance Survey of Great Britain 1936
print(canonical_crs(CRS.from_epsg(27700).to_json()).to_authority()) # ('EPSG', '27700')
to_json() is PROJJSON — WKT2's JSON twin, carrying the same information in a form you can store in a JSONB column or a Parquet metadata field and query without a parser. Prefer it over WKT when the destination is structured storage; prefer WKT when the destination is a file header a GDAL driver will write for you.
Verification
A single reusable assertion catches every lossy definition before it reaches a transform. Run it on any CRS you load from disk, from a config file or from an API response.
from pyproj import CRS, Transformer
from pyproj.exceptions import ProjError
def assert_lossless(crs: CRS) -> None:
assert crs.datum is not None and "Unknown" not in crs.datum.name, \
f"datum was lost: {crs.datum}"
assert crs.area_of_use is not None, "no area of use — PROJ cannot bound the operation"
# A datum-aware CRS can reach WGS 84 without a ballpark step
Transformer.from_crs("EPSG:4326", crs, always_xy=True, allow_ballpark=False)
assert_lossless(CRS.from_epsg(27700))
print("EPSG:27700 OK")
# EPSG:27700 OK
try:
assert_lossless(CRS.from_proj4(
"+proj=tmerc +lat_0=49 +lon_0=-2 +k=0.9996012717 "
"+x_0=400000 +y_0=-100000 +ellps=airy +units=m +no_defs"
))
except (AssertionError, ProjError) as exc:
print("rejected:", exc)
# rejected: datum was lost: Unknown based on Airy 1830 ellipsoid
The WKT2 round trip should pass the same check unchanged, which is the whole argument for using it as the storage format:
crs = CRS.from_epsg(27700)
assert CRS.from_wkt(crs.to_wkt(version="WKT2_2019")) == crs
assert CRS.from_json(crs.to_json()) == crs
assert_lossless(CRS.from_wkt(crs.to_wkt(version="WKT2_2019")))
print("WKT2 and PROJJSON round-trip cleanly")
# WKT2 and PROJJSON round-trip cleanly
Edge Cases & Debugging
to_epsg()returns a code but transforms are still wrong. The default confidence of 70 accepts a parameter-level match. Re-check withcrs.to_epsg(min_confidence=100);Nonemeans the object is not the registry's definition, whatever the code says.- WKT1 from a shapefile
.prjloses the area of use.CRS.from_wkt(wkt1)keeps the datum name, so transformations still resolve correctly, butarea_of_usecomes backNoneandassert_losslessabove will reject it. Re-attach the authority withcanonical_crs()rather than trusting the sidecar, and see automating shapefile cleanup with Python for the wider.prjrepair pass. - A 3D or compound CRS silently flattens.
CRS.from_epsg(4979).to_proj4()produces the identical string asEPSG:4326— the ellipsoidal height axis has nowhere to go, and reading it back yields a 2D CRS. Never serialize a vertical or compound CRS through a PROJ string. +datum=WGS84looks reassuring and is not. It resolves to the WGS 84 ensemble with roughly 2 m of internal spread, not to a specific realization such as WGS 84 (G2139). Where centimetre accuracy matters, name the realization by code instead.- PostGIS rejects your SRID.
spatial_ref_sysis populated from the PostGIS build's own EPSG snapshot; a code added in a newer dataset release will be missing. Insert the row with the WKT2 text fromcrs.to_wkt()— the mechanics of the connection are in connecting GeoPandas to PostGIS with SQLAlchemy. - A layer written from an unlabelled frame. If
gdf.crs is None, GeoPandas writes no CRS at all and the next reader guesses. Declare the known source withset_crs()before writing, and reproject only withto_crs().
Frequently Asked Questions
Is a PROJ string ever the right choice?
Yes, in exactly one place: as a live argument to PROJ when you are describing an operation rather than storing a CRS. Pipeline definitions such as +proj=pipeline +step ... and one-off ad-hoc projections passed straight into Transformer.from_pipeline() are legitimate PROJ-string territory, because nothing is being persisted and no datum identity needs to survive. The moment the text lands in a file, a column or a config value, it is the wrong format.
Which WKT version should I write?
WKT2_2019 unless a consumer forces your hand. It is the only dialect that expresses datum ensembles, dynamic reference frames, and the usage/area-of-use blocks PROJ relies on to choose a transformation. Write WKT1_GDAL only for legacy consumers such as a shapefile .prj or an older ArcGIS toolchain, and accept that the area of use will not survive. CRS.to_wkt() defaults to the WKT2:2019 dialect, so calling it without arguments is already the safe choice.
Does an EPSG code mean the same thing forever?
The code is stable but the registry behind it is versioned, and EPSG periodically deprecates entries or refines the transformation operations attached to a datum pair. crs.is_deprecated flags a retired definition, and pinning pyproj in your requirements pins the bundled proj.db with it, so a dependency bump cannot quietly change your numbers. For a reproducible archive, store the code and the WKT2 text, exactly as GeoPackage and GeoParquet do.
How do I check what a file actually claims before loading it?
Read the CRS without reading the geometry: pyogrio.read_info("parcels.gpkg")["crs"] returns the layer's CRS text, and rasterio.open("ortho.tif").crs.to_wkt() does the same for a raster. Feed either into canonical_crs() and you know within one call whether the file carries a real datum or a hollowed-out projection definition. The broader reprojection workflow this feeds is covered in Coordinate Reference System Transformations.