This page is part of CRS Normalization Across Mixed Datasets, which itself sits within the broader Automated Vector & Raster Cleaning Workflows reference.
Converting mixed EPSG codes to a unified CRS in Python
When a spatial pipeline ingests vector boundaries in EPSG:27700 (British National Grid), point sensors in EPSG:4326 (WGS 84), and raster imagery in EPSG:32630 (UTM zone 30N), every downstream spatial join, distance calculation, and rasterization step operates on geometrically incompatible coordinate spaces. The result is not always an explicit error β GeoPandas will happily join two GeoDataFrame objects with mismatched CRS attributes, producing geometrically nonsensical output that passes dtype checks but fails silently at the row level.
This page gives you a single, copy-pasteable Python function that validates every input against the EPSG registry, detects datum-shift transitions, and reprojects to a canonical target CRS before concatenation.
Why mixed EPSG codes break spatial pipelines
- Silent spatial join failures.
GeoDataFrame.sjoin()does not reproject inputs automatically. Feeding two frames with different CRS attributes produces empty or wildly incorrect match sets without raising an exception. - Accumulated coordinate rounding errors. Each unnecessary round-trip reprojection introduces floating-point drift. Validating EPSG codes once at ingest and transforming in a single pass keeps precision loss within acceptable bounds β a concern addressed in depth on the Handling Precision & Coordinate Rounding page.
- Datum-shift offsets reaching 100 m. Converting NAD27 or OSGB36 data to WGS 84 without grid-based correction files introduces region-dependent offsets large enough to misplace parcel boundaries or sensor readings by a city block.
- Registry mismatches swallowing deprecated codes. Older datasets carry EPSG codes retired from the registry (e.g., some early ESRI custom codes).
pyprojwill raiseCRSErroron these unless you have a fallback path that logs and continues.
Version and environment compatibility
| pyproj | GeoPandas | Shapely | Notes |
|---|---|---|---|
| β₯ 3.6 | β₯ 0.14 | β₯ 2.0 | Recommended. Full PROJ 9 datum-grid support, Shapely 2 vectorised API. |
| 3.3β3.5 | 0.12β0.13 | 1.8β2.0 | CRS.from_user_input() available. PROJ 8 grid downloads require explicit PROJ_NETWORK=ON env var. |
| 3.0β3.2 | 0.10β0.11 | 1.7β1.8 | Deprecated pyproj.Proj() init patterns may silently ignore malformed WKT. Upgrade before production use. |
| < 3.0 | < 0.10 | < 1.7 | Do not use. Silent coordinate shifts on malformed PROJ strings. No EPSG registry validation. |
Verify your runtime before running the function:
import pyproj, geopandas, shapely
print(pyproj.__version__, geopandas.__version__, shapely.__version__)
# Confirm PROJ data directory is populated
print(pyproj.datadir.get_data_dir())Pipeline flow: EPSG validation and unified reprojection
The diagram below shows how each input dataset moves through registry validation, datum-shift detection, and CRS transformation before the frames are concatenated into a single coherent output.
Production-ready conversion function
The function below accepts a list of file paths or existing GeoDataFrame objects, validates each CRS, flags datum-shift transitions, and reprojects to the target EPSG in a single pass. It returns a concatenated GeoDataFrame with a _source_epsg audit column appended.
import geopandas as gpd
import pyproj
from pyproj.exceptions import CRSError
import shapely
import logging
import numpy as np
from pathlib import Path
from typing import Iterable, Union
import pandas as pd
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
# Datums known to require grid-based corrections when converting to WGS 84 / ETRS89
_DATUM_SHIFT_DATUMS = frozenset(["NAD27", "OSGB 1936", "Tokyo", "ED50", "Pulkovo 1942"])
def _detect_datum_shift(source_crs: pyproj.CRS, target_crs: pyproj.CRS) -> bool:
"""Return True if the sourceβtarget transition crosses a datum that needs grid corrections."""
try:
src_datum = source_crs.datum.name if source_crs.datum else ""
tgt_datum = target_crs.datum.name if target_crs.datum else ""
return src_datum in _DATUM_SHIFT_DATUMS and src_datum != tgt_datum
except Exception:
return False
def unify_mixed_epsg(
datasets: Iterable[Union[gpd.GeoDataFrame, Path, str]],
target_epsg: int = 4326,
strict: bool = True,
enable_network: bool = True,
) -> gpd.GeoDataFrame:
"""
Validate and reproject datasets carrying mixed EPSG codes to a single target CRS.
Parameters
----------
datasets:
Iterable of file paths (str/Path) or already-loaded GeoDataFrame objects.
target_epsg:
EPSG code for the unified output CRS. Default 4326 (WGS 84 geographic).
strict:
If True, raise CRSError on undefined or unresolvable CRS. If False, log and
skip the offending dataset so the pipeline continues.
enable_network:
Allow PROJ to download datum-shift grid files on demand. Recommended when
inputs may carry NAD27, OSGB36, or other legacy datums.
Returns
-------
gpd.GeoDataFrame
Concatenated frame in target_epsg with a ``_source_epsg`` audit column.
"""
if enable_network:
pyproj.network.set_network_enabled(True)
logger.info("PROJ network access enabled for datum-grid downloads.")
target_crs = pyproj.CRS.from_epsg(target_epsg)
logger.info("Target CRS: %s (EPSG:%s)", target_crs.name, target_epsg)
converted_frames: list[gpd.GeoDataFrame] = []
for idx, ds in enumerate(datasets):
# Load from disk or copy an existing frame to avoid mutating caller's data
gdf: gpd.GeoDataFrame = (
gpd.read_file(ds) if isinstance(ds, (Path, str)) else ds.copy()
)
if gdf.empty:
logger.warning("Dataset %s is empty β skipping.", idx)
continue
# ββ 1. Check CRS is defined ββββββββββββββββββββββββββββββββββββββββββ
if gdf.crs is None:
msg = f"Dataset {idx} has undefined CRS."
if strict:
raise CRSError(msg + " Cannot proceed in strict mode.")
logger.warning("%s Assigning target CRS as fallback.", msg)
gdf = gdf.set_crs(target_crs)
gdf["_source_epsg"] = None
converted_frames.append(gdf)
continue
# ββ 2. Parse and validate against the EPSG registry βββββββββββββββββ
try:
source_crs = pyproj.CRS.from_user_input(gdf.crs)
source_epsg = source_crs.to_epsg()
if source_epsg is None:
logger.warning(
"Dataset %s has no EPSG code (custom/compound CRS). WKT: %s",
idx,
source_crs.to_wkt()[:120],
)
else:
logger.info("Dataset %s validated: EPSG:%s", idx, source_epsg)
except CRSError as exc:
if strict:
raise CRSError(f"Dataset {idx} has invalid CRS: {exc}") from exc
logger.error("Dataset %s CRS parse failed: %s β skipping.", idx, exc)
continue
# ββ 3. Detect datum shifts requiring grid corrections ββββββββββββββββ
if _detect_datum_shift(source_crs, target_crs):
logger.warning(
"Dataset %s crosses a datum shift (%s β %s). "
"Ensure PROJ grid files are available for sub-metre accuracy.",
idx,
source_crs.datum.name,
target_crs.datum.name,
)
# Skip transform if already in target CRS
if source_crs.equals(target_crs):
logger.info("Dataset %s already in target CRS β no transform needed.", idx)
gdf["_source_epsg"] = source_epsg
converted_frames.append(gdf)
continue
# ββ 4. Reproject βββββββββββββββββββββββββββββββββββββββββββββββββββββ
try:
reprojected = gdf.to_crs(target_crs)
except Exception as exc:
if strict:
raise
logger.error("Reprojection failed for dataset %s: %s β skipping.", idx, exc)
continue
# ββ 5. Validate output geometry (Shapely 2 vectorised) βββββββββββββββ
validity_arr = shapely.is_valid(reprojected.geometry.values)
invalid_count = int(np.sum(~validity_arr))
if invalid_count:
logger.warning(
"Dataset %s: %s geometries invalid after reprojection. "
"Run geometry repair before downstream joins.",
idx,
invalid_count,
)
reprojected["_source_epsg"] = source_epsg
converted_frames.append(reprojected)
if not converted_frames:
logger.warning("No valid datasets to concatenate. Returning empty GeoDataFrame.")
return gpd.GeoDataFrame(geometry=gpd.GeoSeries([], crs=target_crs))
unified = gpd.GeoDataFrame(
pd.concat(converted_frames, ignore_index=True),
crs=target_crs,
)
logger.info(
"Unified GeoDataFrame: %s rows, CRS=%s", len(unified), target_crs.name
)
return unifiedKey implementation notes
-
pyproj.CRS.from_user_input()over bare integer casting. Passing an integer EPSG directly tofrom_epsg()skips normalisation of WKT or PROJ string inputs that arrive from file headers.from_user_input()handles all three formats and returns the same canonical object, making the validation step format-agnostic. -
.to_epsg()returningNoneis not a fatal error. Compound CRS, engineering CRS, and custom local projections often lack EPSG codes. The function logs the WKT fingerprint for audit purposes and continues β only truly malformed definitions (those that raiseCRSError) trigger the strict-mode halt. -
Datum-shift detection is advisory, not blocking. The
_detect_datum_shifthelper flags transitions that carry accuracy implications. Blocking on datum shifts would reject valid data; warning gives the pipeline operator the information needed to verify grid file availability without halting the run. -
Shapely 2 vectorised validity check.
shapely.is_valid(gdf.geometry.values)operates on the underlying NumPy geometry array in a single C-layer call rather than iterating with.apply(). On a 500 k-row dataset this is roughly 40Γ faster than the Shapely 1.apply(lambda g: g.is_valid)pattern. Geometries invalidated during reprojection β typically caused by precision snapping at antimeridian crossings β are logged rather than silently lost. -
_source_epsgaudit column. Recording the original EPSG code on each output row satisfies data governance requirements and makes it possible to trace coordinate anomalies back to their source file in post-run debugging. The column carriesNonefor inputs whose CRS lacked an EPSG code. -
Network access and air-gapped environments. In environments without outbound internet access, set
enable_network=Falseand mount a pre-downloaded PROJ datum grid directory to the path returned bypyproj.datadir.get_data_dir(). Without grid files, NAD27βWGS 84 conversions silently fall back to a Helmert 7-parameter approximation, introducing errors of 10β100 m depending on region.
Troubleshooting reference
| Symptom | Root Cause | Fix |
|---|---|---|
CRSError: Invalid projection: EPSG:XXXXX |
EPSG code absent from local PROJ database | Update PROJ data dir or enable network access |
.to_epsg() returns None on valid CRS |
Custom or compound CRS with no EPSG authority entry | Log WKT, proceed with transformation using the CRS object directly |
| NAD27 offsets > 1 m after conversion | NADCON5/NTv2 grid files missing | Enable network or mount /usr/share/proj with grid files |
| Empty GeoDataFrame after concat | All inputs failed validation | Check strict=False logs; inspect raw file CRS with gdf.crs |
Geometries invalid after to_crs() |
Antimeridian crossing or precision snapping | Run geometry repair with Shapely/GeoPandas as next step |
Integration note
This function slots in immediately after file ingest and before any attribute-level operations in the broader CRS Normalization Across Mixed Datasets workflow. Once unify_mixed_epsg() returns a single GeoDataFrame in a consistent coordinate space, coordinate-sensitive steps such as handling precision and coordinate rounding and removing duplicate spatial points with tolerance thresholds can operate on geometrically compatible data without re-validating projection assumptions.
In DAG-based pipelines (Airflow, Prefect, Dagster), wrap this function in a single task node and pass its output path β written as GeoParquet or FlatGeobuf β to the next task. The _source_epsg audit column survives the serialisation round-trip and remains available for lineage queries without reprocessing source files.
Related
- CRS Normalization Across Mixed Datasets β parent guide covering the full detection-to-normalisation workflow for both vector and raster sources
- Handling Precision & Coordinate Rounding β apply after CRS unification to eliminate floating-point noise without topology damage
- Fixing Self-Intersecting Polygons in GeoPandas β repair geometries that become invalid after cross-datum reprojection
- Removing Duplicate Spatial Points with Tolerance Thresholds β deduplicate point datasets once all sources share a common CRS