Step-by-Step EPSG Code Normalization for Mixed Datasets Jump to heading
Geospatial ETL pipelines routinely ingest shapefiles, GeoJSON, PostGIS exports, and LiDAR derivatives that carry inconsistent or missing coordinate reference system metadata. Schema drift introduces ambiguous srs_name fields, malformed .prj strings, and deprecated EPSG aliases. Downstream spatial joins, distance calculations, and spatial-index alignments then fail silently or produce metric-scale offsets. This procedure is one of the Projection Normalization Workflows inside CRS Normalization & Sync: it eliminates ambiguity by enforcing deterministic CRS resolution, applying strict precision thresholds, and validating topology before data enters production stores. It assumes you have already decided which target EPSG code to standardize on — the surrounding workflow owns that policy; this page owns the runnable steps that enforce it across a heterogeneous batch.
The walkthrough maps 1:1 to the ETL phases this site standardizes on — configure (Steps 1-2, inventory and target assignment), execute (Step 3, transform), validate (Step 4), and log (Step 5, CI gate and lineage) — so each step drops straight into an existing stage rather than living as a standalone script. If your inputs span many authority CRS at once, run Multi-CRS Dataset Harmonization to group them first, then apply the steps below per source-CRS bucket.
Prerequisites checklist Jump to heading
Confirm the environment before running any normalization pass. Missing PROJ grids are the single most common cause of silently degraded transformations, so verify grid availability explicitly rather than assuming the wheel shipped them.
Step 1: Inventory extraction and metadata sanitization Jump to heading
Begin by scanning all input layers for explicit CRS declarations. Relying on file extensions or implicit assumptions introduces immediate pipeline risk. Parse .prj files, WKT strings, and EPSG integers through pyproj.CRS.from_user_input() wrapped in a strict exception handler. Discard any CRS that fails validation against the EPSG registry or raises a CRSError, and log invalid entries to a quarantine manifest rather than halting execution.
# geopandas >= 0.14, pyproj >= 3.6
import logging
from pathlib import Path
import geopandas as gpd
from pyproj import CRS
from pyproj.exceptions import CRSError
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
logger = logging.getLogger(__name__)
def extract_valid_crs(source_path: Path) -> str | None:
"""Parse CRS metadata with strict validation and quarantine routing."""
try:
# Read the header only to prevent memory exhaustion on large datasets.
gdf = gpd.read_file(source_path, rows=1)
if gdf.crs is None:
logger.warning("Missing CRS field in %s", source_path.name)
return None
crs = CRS.from_user_input(gdf.crs)
epsg_code = crs.to_epsg()
if epsg_code:
return f"EPSG:{epsg_code}"
# Fall back to validated WKT for non-EPSG but structurally sound projections.
return crs.to_wkt()
except CRSError as exc:
logger.error("CRS validation failed for %s: %s", source_path.name, exc)
return None
except Exception as exc: # noqa: BLE001 — quarantine, never crash the batch
logger.error("CRS extraction failed for %s: %s", source_path.name, exc)
return None
Validation rules and thresholds:
- Reject any dataset lacking a resolvable CRS under zero-trust ingestion policies.
- Limit header sampling to
rows=1to prevent OOM errors on multi-gigabyte LiDAR tiles. - Route malformed WKT or unrecognized authority codes to a structured quarantine manifest.
- Enforce schema validation before bulk harmonization begins.
Step 2: Canonical target assignment and datum fallback Jump to heading
Define a single target EPSG code for the normalized output. Government and enterprise pipelines typically standardize on EPSG:4326 for interchange or a regional metric projection for analytical workloads. For datasets that need a datum shift, route them through a deterministic fallback chain rather than letting PROJ silently pick a transformation. When crossing legacy datum boundaries (NAD27 → NAD83 → WGS84), prefer grid-based shifts (NADCON5, NTv2) over Helmert approximations; the Datum Transformation Fallback Chains strategy owns that routing contract and the missing-grid recovery logic. Exceeding the configured tolerance triggers a halt and routes the asset to a manual reconciliation queue.
# pyproj >= 3.6
from pyproj import CRS
from pyproj.transformer import TransformerGroup
TARGET_EPSG = 4326
# Tolerances are enforced numerically; see Unit Conversion & Tolerance Thresholds for the matrices.
MAX_DEVIATION_METERS = 0.001
MAX_DEVIATION_DEGREES = 0.000001
def select_transformation(source_epsg: int, target_epsg: int = TARGET_EPSG):
"""Pick the most accurate available path; reject if the best path is grid-deficient."""
group = TransformerGroup(
CRS.from_epsg(source_epsg),
CRS.from_epsg(target_epsg),
always_xy=True,
)
if not group.transformers:
raise RuntimeError(f"No transformation path {source_epsg} -> {target_epsg}")
if group.unavailable_operations:
# A more accurate grid-based path exists but its grid is not installed.
missing = group.unavailable_operations[0].name
raise RuntimeError(f"Required grid missing for best path: {missing}")
return group.transformers[0]
Transformation tolerance rules:
- Maximum allowable deviation:
0.001meters for metric targets. - Maximum allowable deviation:
0.000001degrees for geographic targets. - Require explicit grid shift files (NTv2, NADCON) when crossing legacy datum boundaries.
- Block transformations that rely on
unknownorapproximatemethods when positional accuracy is critical.
Step 3: Deterministic transformation and axis enforcement Jump to heading
Axis-order inversion remains a primary source of coordinate drift in modern GDAL/PROJ environments. Always instantiate transformers with always_xy=True to enforce (longitude, latitude) / (easting, northing) ordering regardless of what the CRS authority definition says. This matters most for EPSG:4326, which is officially (latitude, longitude) in ISO 19111 axis order, so a naive reproject can swap every coordinate.
# geopandas >= 0.14, pyproj >= 3.6
import geopandas as gpd
from pyproj import CRS, Transformer
from pyproj.exceptions import CRSError
def normalize_crs(gdf: gpd.GeoDataFrame, target_epsg: int) -> gpd.GeoDataFrame:
"""Apply a deterministic CRS transformation with axis-order enforcement."""
if gdf.crs is None:
raise ValueError("Input GeoDataFrame lacks CRS metadata. Rejecting.")
source_crs = CRS.from_user_input(gdf.crs)
target_crs = CRS.from_epsg(target_epsg)
if source_crs.equals(target_crs):
return gdf # No-op; avoid unnecessary resampling.
# Verify the chain resolves before committing the full dataset.
try:
Transformer.from_crs(source_crs, target_crs, always_xy=True)
except CRSError as exc:
raise RuntimeError(
f"Transformation chain cannot be resolved "
f"({source_crs.to_epsg()} -> {target_epsg}). "
f"Check PROJ grid availability. Original error: {exc}"
)
return gdf.to_crs(target_crs)
Execution rules:
- Always pass
always_xy=Trueto prevent latitude/longitude inversion. - Confirm
Transformer.from_crs()succeeds before applying geometry operations to the full dataset. - Reject transformations that produce
NaN,Inf, or coordinates outside the valid EPSG bounding box. - Maintain original geometry column names to preserve downstream schema contracts.
Step 4: Precision thresholds and topology validation Jump to heading
Post-transformation validation prevents metric-scale offsets from propagating into spatial indexes. Validate coordinate bounds, precision decay, and geometric integrity immediately after the projection shift, and run topology checks to catch self-intersections or collapsed geometries introduced during resampling. The numeric deviation matrices behind these gates are standardized in Unit Conversion & Tolerance Thresholds; this step only enforces them.
# geopandas >= 0.14, shapely >= 2.0
import geopandas as gpd
from pyproj import CRS
from shapely import make_valid
def validate_geometry(gdf: gpd.GeoDataFrame, target_epsg: int) -> gpd.GeoDataFrame:
"""Repair invalid geometry and drop anything outside the target domain of validity."""
repaired = gdf.geometry.apply(lambda g: g if g.is_valid else make_valid(g))
gdf = gdf.assign(geometry=repaired)
bounds = CRS.from_epsg(target_epsg).area_of_use
if bounds is not None:
in_domain = gdf.cx[bounds.west:bounds.east, bounds.south:bounds.north]
dropped = len(gdf) - len(in_domain)
if dropped:
logger.warning("Dropped %d features outside EPSG:%d domain", dropped, target_epsg)
gdf = in_domain
return gdf[gdf.geometry.notna() & gdf.geometry.is_valid]
Precision and topology rules:
- Coordinate precision must not exceed
1e-9for geographic targets or1e-3for metric targets. - Reject geometries with
is_valid == False; attemptshapely.make_validrepair before quarantining. - Enforce a minimum bounding-box area to filter collapsed polygons (area
< 1 cm²in the target CRS is a removal candidate). - Confirm transformed coordinates fall within the target EPSG’s official domain of validity via
CRS.from_epsg(target_epsg).area_of_use.
Step 5: CI integration and failure routing Jump to heading
Automate normalization validation inside continuous integration. Treat CRS mismatches as hard failures in staging, route quarantined datasets to structured error queues with actionable remediation metadata, and reserve retry logic for transient network failures when fetching remote grids — the Error Handling & Retry Logic patterns wrap the normalizer rather than living inside it. At scale, group inputs by source CRS before running, as covered in Multi-CRS Dataset Harmonization.
# .github/workflows/crs-normalize.yml
name: CRS Normalization Gate
on: [pull_request]
jobs:
normalize:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install dependencies
run: pip install "geopandas>=0.14" "pyproj>=3.6" "shapely>=2.0" "pytest>=7"
- name: Sync required PROJ grids
run: pyproj sync --source-id us_noaa --source-id ca_nrc
- name: Run normalization tests
run: pytest tests/test_epsg_normalization.py -v --tb=short
CI compliance rules:
- Fail CI builds on unresolvable CRS or missing datum-grid dependencies.
- Log transformation metadata (source EPSG, target EPSG, method, tolerance) to pipeline artifacts.
- Implement circuit breakers for bulk transformations exceeding memory or time thresholds.
- Require manual sign-off for datasets routed to the reconciliation queue before a production merge.
Verification Jump to heading
Confirm a normalization pass actually succeeded — do not trust an exit code of 0 alone. Assert the output CRS, the absence of unprojected coordinates, and a clean quarantine ledger:
out = normalize_crs(gdf, 4326)
out = validate_geometry(out, 4326)
# 1. The output is on the canonical target.
assert out.crs.to_epsg() == 4326, f"unexpected CRS {out.crs.to_epsg()}"
# 2. No coordinate escaped the target domain of validity.
minx, miny, maxx, maxy = out.total_bounds
assert -180.0 <= minx and maxx <= 180.0, "longitude out of EPSG:4326 bounds"
assert -90.0 <= miny and maxy <= 90.0, "latitude out of EPSG:4326 bounds — check always_xy"
# 3. Every surviving geometry is valid.
assert out.geometry.is_valid.all(), "invalid geometry survived validation"
A healthy run prints no CRS validation failed or Required grid missing lines, and the quarantine manifest is empty. On the CLI, ogrinfo -so output.gpkg layer | grep "Geometry Column" should report the expected SRS, and projinfo EPSG:4326 resolving offline confirms the grids are wired in.
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Coordinates appear swapped (lat where lon should be) | Transformer built without always_xy=True; EPSG:4326 used its ISO authority axis order |
Rebuild every Transformer.from_crs(...) with always_xy=True; re-run Step 3 and the bounds assertion. |
CRSError: Invalid projection on read |
Malformed .prj / WKT or a deprecated EPSG alias in the source |
Route the file to the quarantine manifest (Step 1); re-issue with a canonical EPSG via CRS.from_user_input. |
| Output is shifted 1–2 m from a reference layer | Helmert approximation used instead of the grid-based path because the NTv2/NADCON grid is missing | pyproj sync the required grid, then re-select the path in Step 2 (unavailable_operations will clear). |
NaN/Inf coordinates after to_crs |
Source geometry fell outside the transformation’s domain of validity | Clip to area_of_use in Step 4 before transforming, or reject the offending features to quarantine. |
| Polygons vanish after validation | Collapsed/degenerate geometry below the minimum-area threshold introduced during resampling | Inspect with make_valid; if area < 1 cm² in the target CRS, the removal is correct — flag upstream digitizing quality. |
Related Jump to heading
- Projection Normalization Workflows — the parent workflow that sets target-EPSG policy for this procedure
- Datum Transformation Fallback Chains — deterministic routing when a direct datum shift is grid-deficient
- Unit Conversion & Tolerance Thresholds — the deviation matrices behind the precision gates in Step 4
- Multi-CRS Dataset Harmonization — grouping and merging inputs that arrive on many different authority CRS
- Error Handling & Retry Logic — backoff and circuit-breaker wrappers for transient grid-fetch failures