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.
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:4326by 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 inresolve_source_crs. - Axis-order inversion (lat/lon vs lon/lat).
pyprojhonours the authority’s axis order by default, which forEPSG:4326is lat-then-lon. Always passalways_xy=TruetoTransformer.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. .prjpresent but malformed. A.prjcontaining an Esri WKT variant thatpyprojcannot parse will load asNoneor an unusable CRS. Don’tset_crsover it blindly; resolve the intended EPSG from the provider and override explicitly withallow_override=Trueonly 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.
Related
- CRS Alignment for Coastal GIS Projects — the full compound-CRS alignment workflow this debug guide feeds into
- Tidal Datum Transformations in Python — resolving MLLW, NAVD88, and LAT vertical references after horizontal alignment
- Parsing AIS NMEA Sentences with Python — sanitising vessel positional fixes before they enter the CRS pipeline
- Understanding NetCDF vs GeoTIFF for Marine Data — where CRS metadata lives in each raster format
Parent: CRS Alignment for Coastal GIS Projects
Authoritative CRS definitions and transformation matrices are maintained by the pyproj project and the GeoPandas projection documentation.