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 codeEPSG: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 same CRS written three ways, and what each notation keeps A comparison matrix for OSGB36 British National Grid expressed as the EPSG code, as a PROJ string, and as WKT2 2019. The EPSG code is ten characters, resolves the datum and area of use through proj.db, is opaque without that database, and matches the registry exactly. The PROJ string is 112 characters, drops the datum entirely, never carries an area of use, is self-contained maths, and fails a strict registry match. WKT2 is 1163 characters, embeds the datum, embeds the bounding box and scope, is fully self-describing, and matches the registry exactly. A footer notes that only the PROJ string loses the datum, and a CRS with no datum can only be joined to another by a ballpark shift. One CRS, three notations — and what each one keeps EPSG:27700 authority code +proj=tmerc … PROJ string PROJCRS[…] WKT2:2019 Characters of text 10 112 1 163 Datum identity OSGB 1936 · via proj.db ✗ dropped entirely OSGB 1936, spelled out Area of use bbox from the registry ✗ never encoded BBOX + scope, inline Self-contained on disk ✗ opaque without proj.db ✓ the maths, in full ✓ fully self-describing Strict registry match 27700 None 27700 Only the PROJ string loses the datum — and a CRS with no datum can only be joined to another by a ballpark shift.
The row that matters is datum identity: the code and the WKT both preserve it, the PROJ string has nowhere to put 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.

What a PROJ string round trip deletes from a CRS A left-to-right flow. A CRS built from EPSG 27700, OSGB36 British National Grid, is converted with to_proj4 into a 112-character PROJ string holding only projection parameters, then rebuilt with from_proj4. The rebuilt object reports name unknown, datum Unknown based on Airy 1830 ellipsoid, and area of use None. A panel lists what fell out: the datum OSGB 1936, the area-of-use bounding box, the authority identifier 27700, the scope and usage text, the official OSGB36 to WGS 84 transformation, and the vertical axis of three-dimensional and compound systems. A footer shows the same London point landing at 530043.19, 180358.21 through the intact CRS but 529930.27, 180412.11 through the round-tripped one, 125 metres apart with no exception raised. A PROJ string round trip keeps the maths and deletes the meaning CRS.from_epsg(27700) OSGB36 / British National Grid datum, bbox and ID intact to_proj4() PROJ string — 112 characters +proj=tmerc +lat_0=49 +ellps=airy +units=m from_proj4 name: unknown datum: Unknown (Airy) area_of_use: None Dropped on the way through the PROJ string datum: OSGB 1936 area-of-use bounding box authority ID 27700 scope and usage text the published OSGB36 → WGS 84 transformation the vertical axis of 3D and compound systems Same London point · intact CRS → 530 043.19, 180 358.21 m · round-tripped → 529 930.27, 180 412.11 m 125 m apart — no exception, no warning, just a ballpark datum shift
The round trip is silent by design: PROJ has enough information to project, just not enough to know where on Earth the projection is anchored.

Prerequisites

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
Which CRS notation to store, by where it is being stored A five-row routing table matching a storage context to a notation. A GeoTIFF or cloud-optimized GeoTIFF header should hold WKT2 written by the driver, because the file must stay readable without your registry. A GeoPackage or GeoParquet holds both an EPSG code and the full definition, since the specifications carry both. A PostGIS geometry column holds the SRID integer, joined to the spatial_ref_sys table and cheap to index. A config file, environment variable or command-line flag holds an EPSG colon code string, which is human-auditable and diffable. Anything custom or outside the EPSG registry holds a WKT2 2019 block, because no code exists for it. A footer notes that no row stores a PROJ string, because it is a runtime argument to PROJ rather than a record of what the data is. Where the CRS is going decides how to write it down STORAGE CONTEXT STORE THIS WHY GeoTIFF / COG header WKT2, via the driver the file must stay readable on a machine that has never seen your registry GeoPackage · GeoParquet code + full definition both specifications carry a slot for each; let the writer fill them, never hand-edit PostGIS geometry column SRID integer four bytes per row, joined to spatial_ref_sys and cheap to index Config file, env var, CLI flag "EPSG:27700" humans review it, diffs stay readable, and it validates in one call on load A custom or non-EPSG CRS WKT2:2019 block no code exists for a local grid, so the text itself has to be the definition No row stores a PROJ string — it is a runtime argument to PROJ, not a record of what your data is.
Pick by durability: a self-describing file gets WKT2, a shared database gets the integer code, and a config file gets the human-readable 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

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.