Fixing Legacy Shapefile .prj Mismatches

A legacy Shapefile whose .prj sidecar declares one coordinate reference system while its coordinate values were actually recorded in another is one of the quietest data-corruption bugs in a geospatial annotation pipeline. The file loads without error, the geometries look valid, and every downstream tool trusts the .prj. The fix is counter-intuitive: you must override the CRS with set_crs (a pure relabel, no coordinate movement), never reproject it with to_crs. Reprojecting a mismatched file runs a transform from a CRS the data was never in and scatters features across the globe. Detect the mismatch by testing whether the raw coordinate extent falls inside the declared CRS’s valid bounds; if it does not, override to the true CRS, then reproject to your canonical CRS.

Why a Mislabelled .prj Silently Corrupts Labels

The .prj file is nothing more than a text sidecar holding a WKT string. Nothing enforces that it matches the numbers in the .shp geometry records. Legacy datasets accumulate mismatches for mundane reasons: someone copied a .prj from a neighbouring dataset, an export tool wrote a default WKT regardless of the true projection, or a datum was changed in metadata but never applied to the coordinates. The result is a file that claims EPSG:4326 degrees while storing UTM eastings and northings, or claims a projected metric CRS while holding decimal degrees.

Because the file is internally consistent as bytes, it passes format validation. The corruption only surfaces geographically: a building footprint annotated at longitude 12.5°, latitude 41.9° gets read as if those degrees were metres, or a 500,000 m easting gets read as 500,000 degrees. When your annotation loader reprojects to the training CRS, the transform amplifies the error, and the label lands hundreds or thousands of kilometres from the imagery it belongs to. Every IoU comparison, every raster clip, every tile join against that feature is then wrong — and nothing raised an exception. This is exactly the failure that a disciplined CRS contract at ingestion is meant to stop.

The critical distinction is between relabelling and transforming. set_crs changes only the metadata attached to the geometries — it asserts “these numbers were always in CRS X” and moves nothing. to_crs performs the coordinate math to convert numbers from their currently-labelled CRS into a new one. If the current label is a lie, to_crs faithfully transforms garbage. You repair the lie with set_crs first, and only then is to_crs safe to run.

Deciding Between Override and Reproject

Override versus reproject decision tree for mismatched Shapefile .prj files A flowchart. Start by reading the declared CRS and the raw coordinate extent. Ask whether the coordinates fall inside the declared CRS bounds. If yes, the .prj is correct, so reproject with to_crs to the canonical CRS. If no, the .prj is wrong, so override with set_crs to the true CRS, then reproject with to_crs to the canonical CRS. Both paths end by re-saving the file. Read declared CRS + raw extent gdf.crs and gdf.total_bounds Coordinates inside declared bounds? YES .prj is correct reproject only NO .prj is wrong set_crs(true, override) to_crs(canonical CRS) reproject into pipeline target Re-save repaired file

The branch that trips people up is the NO path. Both branches end at to_crs, but only the wrong-.prj branch first passes through set_crs. Never skip straight from a detected mismatch to to_crs — that is the operation that ruins the geometry.

Step-by-Step Repair

Install the pinned toolchain once:

bash
pip install geopandas==0.14.4 pyproj==3.6.1

Step 1 — Read the Declared CRS and the Raw Extent

Load the file and separate two facts that are easy to conflate: what the .prj claims (gdf.crs) and where the coordinates actually are (gdf.total_bounds). GeoPandas parses the .prj WKT into gdf.crs on read.

python
import geopandas as gpd
from pyproj import CRS

def inspect_shapefile(path: str) -> tuple[CRS | None, tuple[float, float, float, float]]:
    """Return the declared CRS from the .prj and the raw coordinate extent."""
    gdf: gpd.GeoDataFrame = gpd.read_file(path)
    declared: CRS | None = CRS.from_user_input(gdf.crs) if gdf.crs else None
    minx, miny, maxx, maxy = map(float, gdf.total_bounds)
    return declared, (minx, miny, maxx, maxy)

Step 2 — Test the Extent Against the Declared CRS Area of Use

pyproj exposes each CRS’s valid geographic bounds through area_of_use. For a projected CRS those bounds are in longitude/latitude, so transform the raw extent into geographic space using the declared CRS and check containment. A projected file whose coordinates are secretly degrees will fail this test dramatically, and a geographic file whose coordinates are secretly metres will produce out-of-range longitudes.

python
from pyproj import CRS, Transformer

def extent_fits_declared_crs(
    declared: CRS,
    extent: tuple[float, float, float, float],
    margin: float = 0.5,
) -> bool:
    """True if the raw extent plausibly falls inside the declared CRS area of use."""
    aou = declared.area_of_use
    if aou is None:
        return True  # cannot disprove; treat as inconclusive

    minx, miny, maxx, maxy = extent
    to_geographic = Transformer.from_crs(declared, declared.geodetic_crs, always_xy=True)
    try:
        lon_min, lat_min = to_geographic.transform(minx, miny)
        lon_max, lat_max = to_geographic.transform(maxx, maxy)
    except Exception:
        return False

    within_lon = (aou.west - margin) <= lon_min and lon_max <= (aou.east + margin)
    within_lat = (aou.south - margin) <= lat_min and lat_max <= (aou.north + margin)
    return within_lon and within_lat

Step 3 — Decide Override Versus Reproject

Wrap the test in a decision. If the extent fits, the .prj is trustworthy and the file needs only a routine reprojection. If it does not, the .prj is wrong and must be overridden to the CRS the coordinates were truly recorded in — a value you supply from project documentation, the source system, or an educated read of the extent’s magnitude and location.

python
from dataclasses import dataclass

@dataclass
class RepairPlan:
    needs_override: bool
    reason: str

def plan_repair(declared: CRS, extent: tuple[float, float, float, float]) -> RepairPlan:
    """Decide whether the file needs a CRS override before reprojection."""
    if extent_fits_declared_crs(declared, extent):
        return RepairPlan(needs_override=False, reason="coordinates fit declared CRS")
    return RepairPlan(
        needs_override=True,
        reason="coordinates fall outside declared CRS bounds; .prj is mislabelled",
    )

Step 4 — Apply set_crs Then to_crs

On the override path, set_crs(true_crs, allow_override=True) relabels the geometries in place — no coordinate moves — and only afterwards does to_crs(canonical) reproject into your pipeline’s target. On the correct-.prj path you skip set_crs entirely. The canonical target here is EPSG:4326; the first appearance of that code links to the broader CRS reference. Choosing one canonical CRS for the whole pipeline is covered in CRS roundtrip testing with pyproj.

python
def repair_crs(
    path: str,
    true_crs: str,
    canonical: str = "EPSG:4326",
) -> gpd.GeoDataFrame:
    """Detect, override if needed, and reproject a Shapefile to the canonical CRS."""
    gdf: gpd.GeoDataFrame = gpd.read_file(path)
    declared = CRS.from_user_input(gdf.crs)
    extent = tuple(map(float, gdf.total_bounds))

    plan = plan_repair(declared, extent)
    if plan.needs_override:
        # RELABEL only — no coordinates move.
        gdf = gdf.set_crs(true_crs, allow_override=True)

    # Now the label is trustworthy, so transforming is safe.
    return gdf.to_crs(canonical)

The first appearance of EPSG:4326 as the canonical target is deliberate: pick one target CRS and normalise every ingested file to it.

Step 5 — Re-Save the Repaired File

Persist the corrected GeoDataFrame so the emitted .prj finally matches the coordinates. Every downstream reader — QGIS, a tiling job, a training-data loader — now ingests the geometry in the right place without needing to know the file was ever broken.

python
def save_repaired(gdf: gpd.GeoDataFrame, out_path: str) -> None:
    """Write the repaired GeoDataFrame; the new .prj matches the coordinates."""
    gdf.to_file(out_path, driver="ESRI Shapefile")

Symptom, Diagnosis, and the Right Call

Symptom Diagnosis Correct operation
.prj says EPSG:4326 but coordinates are in the hundreds of thousands Coordinates are projected metres; label is geographic set_crs(true_utm, allow_override=True) then to_crs
.prj says a UTM/metric CRS but coordinates are small decimals (−180…180) Coordinates are degrees; label is projected set_crs("EPSG:4326", allow_override=True) then to_crs
Features load at the correct place; only the target CRS differs .prj is accurate; routine change of CRS to_crs only — no override
gdf.crs is None (no .prj) No declared CRS to contradict set_crs(inferred_crs) then to_crs
Reprojected features land in the ocean or at (0, 0) to_crs was run on a mislabelled file Undo, set_crs the true CRS, then to_crs
Extent passes containment test after margin widening .prj correct but near a CRS edge to_crs only; do not override

Common Errors and Fixes

Reprojected annotations land far from the imagery Root cause: to_crs was called on a file whose .prj disagreed with its coordinates, so the transform ran from the wrong source CRS. Fix: re-read the original file, call set_crs(true_crs, allow_override=True) to correct the label, then to_crs(canonical).

ValueError: The CRS attribute of a GeoDataFrame ... already has a CRS which is not equal Root cause: set_crs was called on a file that already carries a CRS, without permission to replace it. Fix: pass allow_override=True — you are intentionally relabelling a mislabelled file.

Override produced no visible change and features are still wrong Root cause: set_crs relabels but never moves coordinates, so if you supplied the wrong true_crs the geometry stays misplaced. Fix: verify true_crs against the extent magnitude and area_of_use before overriding; degree-scale numbers imply a geographic CRS, metre-scale numbers a projected one.

CRSError: Invalid projection when reading the extent Root cause: the .prj WKT is malformed or empty, so gdf.crs is None or unparseable. Fix: treat it as the missing-.prj case — infer the true CRS, assign with set_crs, then reproject.

Round-tripped file still confuses a downstream tool Root cause: field-name truncation or geometry rewriting during to_file, unrelated to the CRS repair. Fix: prefer a modern container; converting Shapefiles to GeoParquet removes the sidecar .prj entirely by embedding the CRS in the file.

This guide covers one repair within Coordinate Reference Systems in Annotation Pipelines, which is itself part of Geospatial Annotation Fundamentals & Architecture.