Reading and Writing .prj Files with Python
A .prj file is a single line of WKT1 sitting next to a shapefile, and it is the most widely deployed piece of CRS metadata in cadastral practice — as well as the least capable of expressing what modern coordinates mean. This guide, part of geospatial file formats and CRS metadata, covers reading one without over-trusting it, writing one that says as much as the format allows, and recording separately the things it cannot say at all.
What WKT1 Can and Cannot Express
WKT1 describes a datum by name, a spheroid by its parameters, a projection by method and parameters, and a unit. That is enough to place coordinates on a map to within the ambiguity of the datum name — which is exactly the problem. It has no construct for a datum realisation, no field for a coordinate epoch, no place to record which coordinate operation produced the data, and only an optional and frequently omitted authority code.
Figure — what a .prj holds, and the three things that have to go elsewhere.
The practical consequences are two. A reader that resolves the datum by name lands on an ensemble: “NAD83” resolves to EPSG:4269, which spans realisations differing by up to about two metres. And a time-dependent frame is unrepresentable, so any deliverable in one needs an accompanying record, as recording coordinate epochs in cadastral deliverables describes.
Complete Runnable Implementation
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from pyproj import CRS
from pyproj.exceptions import CRSError
ENSEMBLE_CODES = {("EPSG", "4326"), ("EPSG", "4269"), ("EPSG", "4258")}
@dataclass(frozen=True)
class PrjReadResult:
"""What a .prj told us, and how much of it we should believe."""
crs: CRS
authority: tuple[str, str] | None
is_ensemble: bool
trusted: bool
note: str
def read_prj(path: Path) -> PrjReadResult:
"""Parse a .prj and classify how specific it actually is.
A .prj is never rejected for being vague — vagueness is the format's normal
state — but the result records how much of the identity is really pinned, so
the uncertainty statement downstream can reflect it.
"""
text = path.read_text(encoding="utf-8-sig").strip()
if not text:
raise ValueError(f"{path} is empty; the layer has no declared CRS")
try:
crs = CRS.from_wkt(text)
except CRSError as exc:
raise ValueError(f"{path} is not parseable as WKT: {exc}") from exc
auth = crs.to_authority()
is_ensemble = auth is not None and tuple(auth) in ENSEMBLE_CODES
if auth is None:
note = "no authority code: definition is usable but unverifiable"
trusted = False
elif is_ensemble:
note = (f"{auth[0]}:{auth[1]} is a datum ensemble; the realisation is "
f"unknown and may differ by up to ~2 m")
trusted = False
else:
note = f"resolved to {auth[0]}:{auth[1]}"
trusted = True
return PrjReadResult(crs, tuple(auth) if auth else None, is_ensemble, trusted, note)
def write_prj(path: Path, crs: CRS, *, allow_ensemble: bool = False) -> None:
"""Write the most specific WKT1 the format can hold.
WKT1_ESRI is the dialect most shapefile consumers expect; WKT1_GDAL keeps more
of the authority information. This writes GDAL's dialect and records the
authority code in the accompanying metadata rather than hoping a reader infers
it from the datum name.
"""
auth = crs.to_authority()
if auth and tuple(auth) in ENSEMBLE_CODES and not allow_ensemble:
raise ValueError(
f"refusing to write the ensemble {auth[0]}:{auth[1]} as a deliverable "
f"CRS; write the realisation the coordinates are in"
)
wkt1 = crs.to_wkt(version="WKT1_GDAL")
path.write_text(wkt1 + "\n", encoding="utf-8")
def sidecar_for(crs: CRS, epoch: float | None, operation_id: str) -> dict:
"""The three things a .prj cannot carry, in a form that ships beside it."""
auth = crs.to_authority()
return {
"crs_authority": f"{auth[0]}:{auth[1]}" if auth else None,
"crs_wkt2": crs.to_wkt(version="WKT2_2019"),
"coordinate_epoch": epoch,
"operation_id": operation_id,
}
Parameter Reference
| Name | Type | Note |
|---|---|---|
path |
Path |
Read with utf-8-sig: .prj files are often BOM-prefixed |
crs |
CRS |
Must be a realisation for a deliverable |
allow_ensemble |
bool |
Escape hatch for genuinely ensemble-level data |
trusted |
bool |
False for ensembles and unmapped definitions |
sidecar_for() |
dict |
Authority code, WKT2, epoch and operation |
Worked Example
from pathlib import Path
from pyproj import CRS
crs = CRS.from_epsg(6339) # NAD83(2011) / UTM zone 10N
write_prj(Path("parcels.prj"), crs)
result = read_prj(Path("parcels.prj"))
print(result.authority, result.trusted, result.note)
# ('EPSG', '6339') True resolved to EPSG:6339
sidecar = sidecar_for(crs, epoch=2020.00, operation_id="EPSG:8264")
print(sidecar["crs_authority"], sidecar["coordinate_epoch"])
# EPSG:6339 2020.0
write_prj(Path("bad.prj"), CRS.from_epsg(4269))
# ValueError: refusing to write the ensemble EPSG:4269 as a deliverable CRS; ...
Figure — three levels of trust in a parsed .prj, and what each implies downstream.
Validation Check
def assert_prj_round_trips(path: Path, intended: CRS) -> None:
"""What landed on disk must still mean what was intended."""
got = read_prj(path).crs
assert got.equals(intended), (
"the CRS read back from the .prj is not the one written; the WKT1 dialect "
"may have dropped parameters the definition needed"
)
Comparing semantically rather than by string is essential: WKT serialisations differ harmlessly in ordering and whitespace, and a string comparison fails on files that are perfectly correct while passing on some that are not.
Common Mistakes
Reading a .prj without the BOM-tolerant encoding. A byte-order mark at the front of the file makes the first keyword unparseable, and the error message points at the WKT rather than at the encoding. utf-8-sig handles both cases and costs nothing.
Writing the ensemble because it is what the source said. If the incoming data was labelled with an ensemble, the outgoing deliverable inherits the ambiguity — but writing it without comment passes the problem on invisibly. Either determine the realisation and record how, or record explicitly that the realisation is unknown.
Assuming a missing .prj means the shapefile has no CRS. It means the CRS is undeclared, which is different: the coordinates are in something, and range-checking them will often say what. That is a starting point for an investigation, not a licence to assume, as handling CRS mismatches in cadastral datasets argues.
When the .prj Disagrees With the Data
A shapefile whose .prj does not match its coordinates arrives regularly, and the useful response is a short diagnostic sequence rather than a guess.
Figure — triage order when a .prj and its coordinates disagree.
Check the magnitudes first. Coordinates in the hundreds of thousands beside a geographic .prj, or values between −180 and 180 beside a projected one, settle the question of which is wrong without any further work: the .prj is describing a different dataset.
Then check the plausible candidates. For projected coordinates, the false easting narrows the zone: a value near 500 000 in a UTM-like system says the point is near a central meridian, and the northing narrows the latitude band. For geographic coordinates, a range check against the claimed area of use usually eliminates all but one candidate.
Then ask. A dataset whose CRS cannot be determined from its own contents needs its producer, not a better heuristic. What matters is that whatever is concluded is recorded as an assumption with its basis, so that a downstream reader can disagree with it.
The temptation throughout is to fix the .prj and move on, because the fix is one line and the file then looks correct. A repaired .prj with no record of the repair is a stronger claim than the evidence supports, and it is exactly the kind of claim that is impossible to unpick years later when the parcel is disputed.
Frequently Asked Questions
Should I write the ESRI or the GDAL WKT1 dialect?
Write whichever the receiving system expects, and find out rather than guess: the two differ in projection parameter names and in how they express units, and a consumer that does not recognise the dialect may fall back to a default. Where there is no stated requirement, the GDAL dialect preserves more authority information and is widely readable.
Can I put a comment in a .prj to record the epoch?
No — WKT1 has no comment syntax, and appending anything after the definition risks breaking parsers. The epoch goes in a sidecar file listed in the delivery manifest. It is tempting to encode it in the layer name instead; that survives about one hand-off.
What if the source .prj and the coordinate ranges disagree?
Trust neither and investigate. A .prj declaring a geographic CRS beside coordinates in the hundreds of thousands is either a projected dataset with the wrong .prj or a units problem, and both are common. Range-checking against the CRS area of use is a cheap first test and usually narrows it to one candidate.
Is it worth writing a .prj at all for an internal file?
Yes, and it costs a line. An internal file becomes an external one the moment somebody copies it, and a file with a CRS is recoverable while a file without one requires archaeology. The same argument applies to the sidecar: write it even for intermediates.
Should the .prj be regenerated or copied when reprojecting?
Regenerated, from the target CRS object, always. Copying the source .prj alongside transformed coordinates produces a file that contradicts itself, and it happens more often than it should because the copy is the path of least resistance in a shell script.