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). pyproj will raise CRSError on 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.

EPSG validation and reprojection pipeline Five-stage pipeline: Parse CRS with pyproj.CRS.from_user_input, validate EPSG code via .to_epsg(), detect datum shift, reproject with to_crs() or rasterio.warp.reproject, then concatenate frames into unified output. Parse CRS from_user_input() Validate EPSG .to_epsg() Datum Shift? NAD27 / OSGB36 Reproject to_crs() / warp Concatenate pd.concat() CRSError or None log + raise / skip Enable PROJ grids set_network_enabled(True) EPSG Validation & Unified Reprojection One pass per input dataset β€” validate before transform mixed EPSG inputs unified GeoDataFrame

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 unified

Key implementation notes

  • pyproj.CRS.from_user_input() over bare integer casting. Passing an integer EPSG directly to from_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() returning None is 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 raise CRSError) trigger the strict-mode halt.

  • Datum-shift detection is advisory, not blocking. The _detect_datum_shift helper 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_epsg audit 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 carries None for inputs whose CRS lacked an EPSG code.

  • Network access and air-gapped environments. In environments without outbound internet access, set enable_network=False and mount a pre-downloaded PROJ datum grid directory to the path returned by pyproj.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.