Fixing CRS Mismatch in QGIS and GeoPandas

A coastal vector layer that draws perfectly over your basemap in QGIS frequently raises pyproj.exceptions.CRSError, or — worse — silently misaligns by hundreds of metres, the moment it enters a headless GeoPandas pipeline. The root difference is behavioural: QGIS reprojects layers on the fly to render them, masking missing or malformed coordinate metadata, while GeoPandas enforces strict coordinate reference system validation through pyproj and refuses to invent a CRS it cannot prove. This page is the step-by-step debug guide for that exact failure, and it sits within the broader CRS alignment workflow for coastal GIS projects, which defines the mandatory pipeline gate every dataset must pass before any spatial operation runs.

Same File, Different Contract: QGIS Rendering vs GeoPandas Validation One source shapefile lacking a .prj sidecar feeds two consumers. On the left, QGIS treats CRS as a rendering concern: it assumes the project CRS, reprojects on the fly, and shows the layer aligned with no error. On the right, GeoPandas treats CRS as a data contract: pyproj validation reads crs as None and raises pyproj.exceptions.CRSError. A centre label reads "same file, different contract". state_jurisdiction.shp no .prj sidecar · coords only QGIS — CRS as rendering assume project CRS / EPSG:4326 nothing written back to source reproject on the fly to render ✓ renders aligned no error · mismatch hidden GeoPandas — CRS as contract read_file → inspect gdf.crs strict pyproj validation gdf.crs is None refuses to invent a CRS ✗ raises on to_crs() pyproj.exceptions.CRSError same file, different contract

Root cause: on-the-fly projection hides what GeoPandas refuses to assume

QGIS treats the CRS as a rendering concern. When a layer’s CRS is undefined, QGIS quietly assumes one — usually the project CRS or EPSG:4326 — and reprojects it on the fly so the geometry lands roughly where the basemap expects. Nothing is written back to the source, and no error is surfaced. GeoPandas treats the CRS as a data contract: an operation that requires a known projection (a reprojection, a distance buffer, a cross-CRS spatial join) will raise rather than guess.

For coastal data this gap is dangerous. A NOAA bathymetry tile delivered as a shapefile without its .prj sidecar, or a state jurisdictional boundary exported with a malformed WKT string, looks correct in the QGIS canvas but fails the instant it reaches a to_crs() call or an intersection against another layer. The mismatch reproduces in two lines:

import geopandas as gpd

# Shapefile missing its .prj sidecar — QGIS rendered it fine on-the-fly
gdf = gpd.read_file("data/state_jurisdiction.shp")
print(gdf.crs)   # -> None

# Any CRS-aware operation now fails or misregisters
gdf.to_crs("EPSG:32618")
# raises: pyproj.exceptions.CRSError: Invalid projection: ... (CRS is undefined)

A subtler variant does not raise: if a layer carries a CRS that QGIS silently overrode on the fly (for example coordinates that are actually EPSG:4269/NAD83 but tagged EPSG:4326/WGS84), GeoPandas honours the tag and the geometry drifts by the datum offset — roughly 1–2 m along the US coast, enough to push a parcel across a jurisdictional line. Recovering the true native datum, rather than the rendered one, is the same discipline applied during tidal datum transformations, where an unverified vertical reference corrupts every downstream elevation.

Step-by-step fix with production code

The fix is deterministic: recover the true source CRS, attach it with set_crs() (which relabels existing coordinates), then move to the target with to_crs() (which transforms them). Conflating the two is the most common way to make a mismatch worse.

1. Confirm what GeoPandas actually sees

Never trust the QGIS canvas for the source CRS. Read the layer headlessly and inspect both the declared CRS and the coordinate ranges, which betray the real units.

import logging

import geopandas as gpd

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s | %(levelname)s | %(message)s",
    datefmt="%Y-%m-%dT%H:%M:%S",
)
logger = logging.getLogger(__name__)


def inspect_layer(path: str) -> gpd.GeoDataFrame:
    gdf = gpd.read_file(path, engine="pyogrio")
    minx, miny, maxx, maxy = gdf.total_bounds
    logger.info("declared crs=%s", gdf.crs)
    logger.info("bounds x=[%.3f, %.3f] y=[%.3f, %.3f]", minx, maxx, miny, maxy)
    return gdf

If the bounds look like x=[-77, -75], y=[37, 40] the data is in degrees (geographic); if they look like x=[300000, 600000], y=[4000000, 4500000] the data is in metres (a UTM/projected grid). This single check usually identifies the true source system even when gdf.crs is None.

2. Recover the authoritative source CRS

Resolve the source from provider metadata, the .prj sidecar, or the coordinate-range heuristic above — and validate it against the pyproj registry so a deprecated or fictitious code is rejected before it propagates.

import pyproj
from pyproj.exceptions import CRSError


def resolve_source_crs(candidate: str) -> pyproj.CRS:
    """Validate a candidate source CRS against the EPSG registry.

    Raises ValueError rather than silently accepting an unknown code,
    so a bad datum can never enter the pipeline.
    """
    try:
        crs = pyproj.CRS.from_user_input(candidate)
    except CRSError as exc:
        raise ValueError(f"Unresolvable source CRS {candidate!r}: {exc}") from exc
    if crs.to_epsg() is None:
        logger.warning("CRS %s has no EPSG code; carrying full WKT", candidate)
    return crs

3. Set the true CRS, then reproject to the target

set_crs() is correct only when the coordinates are already in that system but the label is missing — exactly the missing-.prj case. to_crs() then performs the actual datum-aware transformation to the pipeline target. After any datum shift, repair geometry topology, because sliver self-intersections can appear during reprojection.

TARGET_CRS = "EPSG:32618"  # UTM Zone 18N, WGS84 — standard for NE US coastal ops


def align_layer(
    gdf: gpd.GeoDataFrame,
    source_crs: str,
    target_crs: str = TARGET_CRS,
) -> gpd.GeoDataFrame:
    """Attach the true source CRS (if missing) and reproject to target."""
    resolved = resolve_source_crs(source_crs)

    if gdf.crs is None:
        logger.info("attaching source crs %s to unlabelled coordinates", source_crs)
        gdf = gdf.set_crs(resolved, allow_override=False)
    elif gdf.crs != resolved:
        # The file declares a CRS that conflicts with the verified source.
        # Override only after confirming the declared tag was wrong.
        logger.warning(
            "declared crs %s overridden with verified source %s",
            gdf.crs.to_string(), source_crs,
        )
        gdf = gdf.set_crs(resolved, allow_override=True)

    target = pyproj.CRS.from_user_input(target_crs)
    if gdf.crs != target:
        logger.info("reprojecting %s -> %s", gdf.crs.to_string(), target_crs)
        gdf = gdf.to_crs(target)

    gdf = gdf.copy()
    gdf["geometry"] = gdf["geometry"].make_valid()
    return gdf

For files larger than a few hundred megabytes, stream them through pyogrio or dask-geopandas rather than loading the whole layer; the same chunked-reprojection strategy is documented in the parent CRS alignment for coastal GIS projects workflow.

Verification and acceptance test

A fix that “looks aligned in QGIS again” is not verified. Round-trip a known control point through the transformation and assert the residual is below a sub-centimetre threshold, then assert every geometry is valid. Wire this into CI so the mismatch cannot silently regress.

import math

import pytest
from pyproj import Transformer


def test_reprojection_drift_within_tolerance():
    # Known point: Cape Henlopen, DE — lon/lat (EPSG:4326)
    lon, lat = -75.0941, 38.7870
    fwd = Transformer.from_crs("EPSG:4326", TARGET_CRS, always_xy=True)
    inv = Transformer.from_crs(TARGET_CRS, "EPSG:4326", always_xy=True)

    x, y = fwd.transform(lon, lat)
    lon2, lat2 = inv.transform(x, y)

    # Round-trip degrees -> metres -> degrees must close to < 1 cm
    drift_m = math.dist((lon, lat), (lon2, lat2)) * 111_320
    assert drift_m < 0.01, f"round-trip drift {drift_m:.4f} m exceeds tolerance"


def test_aligned_layer_is_valid(aligned_gdf):
    assert aligned_gdf.crs.to_epsg() == 32618
    assert aligned_gdf.geometry.is_valid.all(), "invalid geometries after reprojection"
    assert aligned_gdf.crs is not None

As a quick CLI cross-check outside Python, confirm what GDAL reads from the file itself — independent of any QGIS project setting:

ogrinfo -so -al data/state_jurisdiction.shp | grep -iE "PROJCRS|GEOGCRS|Layer SRS"

Edge cases and gotchas

  • Deprecated or “WGS84 by RFC” assumptions. GeoJSON is defined as EPSG:4326 by RFC 7946, so files arrive untagged but are not always actually in WGS84 when re-exported by careless tooling. Verify with the coordinate-range heuristic before trusting the default; treat deprecated EPSG codes (e.g. older datum realizations) as a hard reject in resolve_source_crs.
  • Axis-order inversion (lat/lon vs lon/lat). pyproj honours the authority’s axis order by default, which for EPSG:4326 is lat-then-lon. Always pass always_xy=True to Transformer.from_crs, or geometries flip across the diagonal and QGIS — using its own axis handling — may still render them “correctly,” hiding the bug until a spatial join fails.
  • .prj present but malformed. A .prj containing an Esri WKT variant that pyproj cannot parse will load as None or an unusable CRS. Don’t set_crs over it blindly; resolve the intended EPSG from the provider and override explicitly with allow_override=True only after confirming the declared tag was wrong.
  • Compound vertical/horizontal CRS. Coastal survey data may carry a vertical datum alongside the horizontal one. A plain to_crs() moves only the horizontal component; vertical references must be handled separately via the tidal datum transformation pipeline.

Parent: CRS Alignment for Coastal GIS Projects

Authoritative CRS definitions and transformation matrices are maintained by the pyproj project and the GeoPandas projection documentation.