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.

What a .prj can and cannot express Six items. The datum name, the spheroid parameters, the projection method and parameters, and the axis unit are all expressible in WKT1 and are what a .prj holds. The datum realisation is not — WKT1 names a datum, so NAD83(2011) and NAD83(CSRS) both serialise as NAD83. The coordinate epoch has no field at all. The operation that produced the coordinates has no field either. The last three go in the sidecar. datum name in the .prj ambiguous across realisations spheroid parameters in the .prj complete projection + parameters in the .prj complete axis unit in the .prj complete datum realisation NOT expressible sidecar: authority code coordinate epoch no field sidecar: decimal year operation used no field sidecar: operation id

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; ...
How much to trust a parsed .prj Three outcomes of parsing a .prj. A definition that resolves to a realisation-level authority code is trusted and can be used as-is. A definition that resolves to an ensemble code is usable but carries up to about two metres of realisation ambiguity, which belongs in the uncertainty statement. A definition with no authority code at all is usable for transformation and unverifiable against a registry, which should be recorded rather than treated as an error. Usable? Record as Realisation code yes trusted Ensemble code yes ~2 m ambiguity No authority code yes unverifiable

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.

Triaging a .prj that disagrees with its coordinates Three steps in order of cost. Compare the coordinate magnitudes against the declared CRS: values in the hundreds of thousands beside a geographic definition, or between minus 180 and 180 beside a projected one, settle which is wrong immediately. Then test the plausible candidates using the false easting and the coordinate range against each candidate area of use. Then ask the producer. Whatever is concluded is recorded as an assumption with its basis, never as a silent repair. .prj disagrees with the data Compare magnitudes geographic vs projected Test candidates false easting, area of use Ask the producer when the file cannot say Record the assumption never a silent repair if decisive

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.