Debugging CRS and Projection Errors in Python
A prescription map that is silently offset by two metres, a raster clip that returns an all-nodata array with no error, a yield layer that plots in the Gulf of Guinea instead of Iowa — these are the daily coordinate-reference-system failures of a Python ag-GIS pipeline. They rarely raise exceptions. Instead they produce output that looks plausible, passes a naive shape check, and only reveals itself when a planter drives the wrong rows or an agronomist notices a field boundary hanging in open water. This guide is a systematic diagnosis-to-fix reference for the CRS and projection bugs that corrupt geopandas and rasterio workflows, with copy-ready guard functions that convert silent misalignment into loud, early assertion failures.
The failure modes covered here are: a raster and a vector disagreeing on CRS (the empty-clip trap), a layer whose CRS is None, running metric operations on EPSG:4326 degrees, the ~1–2 m NAD83-versus-WGS84 datum offset that ruins RTK-grade prescriptions, axis-order (latitude/longitude versus longitude/latitude) swaps, UTM zone-boundary crossings, and pyproj’s “ballpark transformation” datum-grid warnings. This page is part of the Ag-GIS Data Fundamentals & Spatial Reference Systems section; start with understanding CRS in precision agriculture if you need the conceptual grounding before debugging a live failure.
Prerequisites
Python packages (exact versions tested):
rasterio==1.3.10geopandas==0.14.4pyproj==3.6.1shapely==2.0.4numpy==1.26.4
Install with:
pip install rasterio==1.3.10 geopandas==0.14.4 pyproj==3.6.1 shapely==2.0.4 numpy==1.26.4
Input and environment requirements:
- Every raster carries a populated
.crs(arasterio.crs.CRS) and an affine.transform; every vector layer carries a populatedgdf.crs(apyproj.CRS). Layers withcrs is Noneare the first thing this guide diagnoses. - A designated working CRS for each field — a projected, metric system, almost always the local UTM zone (for example
EPSG:32615for UTM zone 15 N over central Iowa). All metric operations (distance, area, buffering, kriging,rasterio.mask) run in this CRS, never in EPSG:4326. - The PROJ data directory reachable by pyproj. Datum-shift transforms between NAD83 and WGS84 need PROJ transformation grids; the ballpark-transformation warning is resolved in the dedicated guide linked below.
- Field extents small enough to sit inside one UTM zone (a 6°-wide band ≈ 400+ km). Multi-zone operations are flagged as an explicit failure mode.
- RTK-sourced points (±1–3 cm) and yield-monitor points (±1–3 m) — knowing which accuracy tier a layer belongs to decides whether a 1–2 m datum offset is a real bug or below noise.
1. Concept & Why CRS Bugs Stay Silent
A coordinate reference system binds a pair of numbers to a location on the Earth. It has three parts that each fail differently: a datum (the reference ellipsoid and its realisation, e.g. NAD83 versus WGS84), a coordinate system (geographic degrees versus projected metres, and the axis order), and, for projected systems, a projection (e.g. Transverse Mercator for a UTM zone). A bug in any one of the three produces a different signature, and the reason these bugs are so costly in agriculture is that none of them raises a Python exception on its own.
Consider the four dominant patterns:
CRS mismatch (the empty-clip trap). When you clip a raster to a field boundary and the two layers declare different CRS, their coordinate ranges do not overlap. rasterio.mask.mask computes the intersection of the geometry with the raster window, finds nothing, and returns an array full of the nodata value — no error, just empty data. Downstream, an NDVI mean over that array is nan or zero and quietly enters the record for that field. This exact failure and its WarpedVRT fix are the subject of fixing rasterio CRS mismatch errors.
Missing CRS (None). A shapefile without a .prj sidecar or a CSV of GPS points loaded straight into geopandas has crs is None. The geometry numbers are fine, but no library knows what they mean. The dangerous fix is to reproject it (which no-ops or errors), when the correct fix is to assign the CRS you know the data is already in with set_crs. Confusing set_crs with to_crs is one of the most common mistakes and one of the easiest to guard.
Datum shift (the RTK killer). NAD83 and WGS84 were coincident in the 1980s but the North American plate has since carried NAD83 roughly 1–2 m away from WGS84. If a controller logs planting in WGS84 and your field boundary is NAD83, treating them as “close enough” offsets every row by that amount. At the ±1–3 cm accuracy an RTK planter delivers, a 1–2 m shift is catastrophic; at the ±1–3 m accuracy of a yield monitor it may be within noise. Whether the shift matters is an agronomic judgement, not a software default, which is exactly why resolving pyproj datum shift warnings treats it as a deliberate decision rather than a silent transform.
Axis order (lat/lon versus lon/lat). EPSG:4326’s authority definition orders axes latitude-then-longitude. GeoJSON, shapefiles, and virtually every raster store longitude-then-latitude. When pyproj honours the authority order, a point at (42.0 N, 93.6 W) round-trips to coordinates that place the field near the equator off the coast of West Africa. The always_xy=True flag forces pyproj to speak longitude-then-latitude and eliminates the whole class of swap.
The correct working posture is defensive: pick one metric working CRS per field, reproject everything into it at ingest, and assert equality at every boundary so that a mismatch fails loudly instead of producing a plausible wrong number. The rest of this page builds that posture into reusable functions.
2. Step-by-Step Diagnosis and Guards
Step 1 — Inventory the declared CRS of every input
Before touching geometry, print what each layer claims to be. Most misalignments are visible in this inventory alone.
import rasterio
import geopandas as gpd
def describe_crs(label: str, crs) -> None:
"""Print a compact CRS fingerprint for one layer."""
if crs is None:
print(f"{label:<22} CRS = None <-- UNDEFINED, must set_crs before use")
return
from pyproj import CRS
c = CRS.from_user_input(crs)
print(
f"{label:<22} EPSG:{c.to_epsg()} "
f"{'projected' if c.is_projected else 'GEOGRAPHIC(deg)':<15} "
f"datum={c.datum.name if c.datum else '?'}"
)
raster_path = "ndvi_20240715.tif"
field_path = "field_boundaries.gpkg"
with rasterio.open(raster_path) as src:
describe_crs("raster (ndvi)", src.crs)
fields = gpd.read_file(field_path)
describe_crs("vector (fields)", fields.crs)
A typical smoking gun looks like raster EPSG:32615 projected next to vector EPSG:4326 GEOGRAPHIC(deg) — a guaranteed empty clip. Two identical EPSG codes but different datum names point at a datum-shift bug instead.
Step 2 — Classify the failure with a diagnosis function
Encode the flowchart as code so the diagnosis is repeatable rather than a manual eyeball each time.
from pyproj import CRS
def diagnose_pair(crs_a, crs_b) -> str:
"""Return the most likely CRS failure class for two layers."""
if crs_a is None or crs_b is None:
return "MISSING_CRS: one layer has crs=None — assign with set_crs, do not reproject"
a, b = CRS.from_user_input(crs_a), CRS.from_user_input(crs_b)
if a.to_epsg() != b.to_epsg():
# Same underlying datum + axes but different projection is still a mismatch
return f"CRS_MISMATCH: EPSG:{a.to_epsg()} vs EPSG:{b.to_epsg()} — reproject to one"
if a.datum and b.datum and a.datum.name != b.datum.name:
return "DATUM_SHIFT: same code, different datum realisation — transform explicitly"
if a.is_geographic and b.is_geographic:
return "CHECK_AXIS_ORDER: geographic pair — confirm always_xy=True in transformers"
return "CRS_OK: codes and datums agree — inspect transform, resampling, or nodata next"
print(diagnose_pair("EPSG:4326", "EPSG:32615"))
print(diagnose_pair("EPSG:32615", "EPSG:32615"))
Step 3 — Reproject to a single metric working CRS
Once the class is known, collapse everything into one projected working CRS with explicit EPSG codes. Never let a library pick a CRS for you.
WORKING_EPSG = 32615 # UTM 15N — replace with your field's zone
def to_working_crs(gdf: gpd.GeoDataFrame, working_epsg: int = WORKING_EPSG) -> gpd.GeoDataFrame:
"""Reproject a vector layer into the metric working CRS, guarding missing CRS."""
assert gdf.crs is not None, (
"Vector CRS is None. Assign the CRS the data was recorded in with "
"gdf.set_crs('EPSG:4326') BEFORE calling this function — set_crs relabels, "
"to_crs reprojects. Using the wrong one silently corrupts coordinates."
)
out = gdf.to_crs(epsg=working_epsg)
assert out.crs.is_projected, "Working CRS must be projected/metric"
return out
fields_m = to_working_crs(fields)
print(f"fields now in EPSG:{fields_m.crs.to_epsg()}, projected={fields_m.crs.is_projected}")
The set_crs-versus-to_crs distinction is the single most consequential line here. set_crs attaches a label without moving any coordinates; use it when the CRS is missing or wrong-but-known. to_crs recomputes every coordinate; use it to move correctly-labelled data between systems. The mechanics of the WGS84→UTM move are covered in depth in how to convert WGS84 to UTM for farm mapping.
Step 4 — Guard raster/vector boundaries with an assertion
The empty-clip trap is defeated by one assertion placed immediately before any masking, sampling, or overlay call.
from rasterio.crs import CRS as RioCRS
def assert_same_crs(raster_crs, vector_crs, *, tol_context: str = "") -> None:
"""Raise before an operation that requires two layers to share a CRS."""
assert raster_crs is not None, f"Raster CRS is None {tol_context}"
assert vector_crs is not None, f"Vector CRS is None {tol_context}"
a = CRS.from_user_input(raster_crs)
b = CRS.from_user_input(vector_crs)
assert a.to_epsg() == b.to_epsg(), (
f"CRS mismatch {tol_context}: raster EPSG:{a.to_epsg()} != "
f"vector EPSG:{b.to_epsg()}. Reproject the geometry before masking — "
"otherwise the clip returns an all-nodata array with no error."
)
with rasterio.open(raster_path) as src:
fields_r = fields.to_crs(src.crs) # match the raster
assert_same_crs(src.crs, fields_r.crs, tol_context="(pre-mask)")
# ... rasterio.mask.mask(src, fields_r.geometry) is now safe
Step 5 — Verify against a known ground control coordinate
The final guard is empirical: transform a coordinate whose true position you know (a surveyed field corner, an RTK base station, a road intersection you can read off imagery) and assert the result lands where it should, within the accuracy tier of the data.
from pyproj import Transformer
# A surveyed field corner: WGS84 lon/lat and its known UTM 15N easting/northing
KNOWN_LONLAT = (-93.6250, 42.0250) # (lon, lat) — note lon first
KNOWN_UTM15N = (448_236.0, 4_651_890.0) # (easting, northing), metres
tr = Transformer.from_crs("EPSG:4326", f"EPSG:{WORKING_EPSG}", always_xy=True)
e, n = tr.transform(*KNOWN_LONLAT)
print(f"transformed: E={e:.1f} N={n:.1f}")
# RTK-grade tolerance is centimetres; here allow 2 m for a hand-read control point
assert abs(e - KNOWN_UTM15N[0]) < 2.0 and abs(n - KNOWN_UTM15N[1]) < 2.0, (
"Control point missed target — check always_xy, datum, and EPSG code"
)
print("control-point check passed")
If this assertion fires with the coordinates swapped (easting and northing roughly transposed, or the point in the wrong hemisphere), the cause is axis order — the transformer was built without always_xy=True. If it fires with a consistent ~1–2 m miss, the cause is a datum shift. If it fires wildly, the EPSG code is wrong.
3. Key Parameters & Tuning
These are the parameters that most often decide whether a CRS operation is correct. Get them wrong and the pipeline still runs.
| Parameter | Type | Default | Agronomic Effect |
|---|---|---|---|
always_xy |
bool | False |
Passed to pyproj.Transformer.from_crs. Left False, EPSG:4326 uses lat/lon order and swaps coordinates, throwing points to the wrong hemisphere. Set True for every transformer touching farm data so pyproj speaks lon/lat like GeoJSON and shapefiles. |
allow_ballpark |
bool | True |
Passed to Transformer.from_crs. When True, pyproj falls back to a datum-free approximation (the “ballpark” warning) that can be off by 1–2 m. Set False on RTK-grade pipelines so a missing NAD83↔WGS84 grid fails loudly instead of silently degrading planting accuracy. |
accuracy |
float (m) | None |
Rejects any transform whose stated accuracy is worse than this many metres. Set to 0.05 for RTK guidance layers; leave None for ±1–3 m yield data where sub-metre precision is meaningless. |
working CRS (WORKING_EPSG) |
int | — | The single metric EPSG all layers are reprojected into. Must be the field’s UTM zone (or a regional equal-area CRS). A geographic default (4326) breaks every distance, area, and buffer calculation downstream. |
set_crs vs to_crs |
method | — | set_crs relabels without moving coordinates (use for missing/wrong CRS); to_crs recomputes coordinates (use to change a correct CRS). Swapping them either corrupts coordinates or no-ops the reprojection. |
| resampling | enum | nearest |
Passed to rasterio.warp.reproject. Use nearest for categorical rasters (management zones, mask codes) to preserve class values; bilinear for continuous NDVI to avoid blocky reprojection artefacts. |
| grid tolerance | float | grid-defined | The NAD83↔WGS84 transform grid embeds its own accuracy (typically a few cm). Absent the grid, the transform degrades to ballpark ±1–2 m — the difference between an on-row and off-row prescription. |
4. Edge Cases & Failure Modes
The all-nodata clip that never errors. A raster in EPSG:32615 clipped against a boundary in EPSG:4326 returns an array filled with nodata, not an exception. Any np.nanmean over it yields nan or 0.0 and silently poisons that field’s record. The fix is the Step 4 assertion plus reprojecting the geometry to the raster CRS first; the deeper reprojection-based recovery using WarpedVRT is detailed in fixing rasterio CRS mismatch errors.
Metric operations on EPSG:4326 degrees. Calling gdf.buffer(30) or .area on a layer still in EPSG:4326 buffers by 30 degrees and computes area in square degrees. There is no error — just a buffer that engulfs a continent. A one degree of longitude spans ~78 km at 45° latitude, so any distance or area result off a geographic CRS is wrong by four to five orders of magnitude. Guard every metric call with assert gdf.crs.is_projected. This is also why kriging and interpolation must run in UTM, as enforced in interpolating sparse yield monitor data with kriging.
NAD83 relabelled as WGS84. A field boundary digitised in NAD83 (EPSG:4269) but tagged EPSG:4326 in its metadata carries a hidden 1–2 m offset. Because both are geographic degrees, every CRS-equality check passes — the codes even look almost interchangeable to careless code. The only detection is a control-point check (Step 5) or noticing that RTK-logged operations consistently miss the boundary by a fixed metre-scale amount. Transform, don’t relabel, and read resolving pyproj datum shift warnings for the grid setup.
UTM zone-boundary crossing. A field or a farm straddling a 6°-wide UTM meridian (e.g. 90°W between zones 15N and 16N in the US Corn Belt) cannot be represented without distortion in a single zone — points far into the neighbouring zone accumulate scale error that grows to several metres near the far edge. Delivered as two tiles in two zones, they will not merge cleanly and produce a seam. Options: pick one zone and accept bounded distortion for a field that only slightly overhangs, or adopt a regional equal-area CRS such as CONUS Albers (EPSG:5070) for multi-zone operations. Validate the choice with validating coordinate systems for variable-rate maps.
The axis mapping strategy gotcha in mixed toolchains. rasterio and shapely are always lon/lat; raw pyproj.Transformer obeys authority axis order unless always_xy=True; GDAL command-line tools depend on the OAMS_TRADITIONAL_GIS_ORDER setting. A pipeline that hands coordinates between all three can swap axes at exactly one hop. Standardise on always_xy=True everywhere pyproj is constructed directly, and never hand-build a transformer without it.
Float32 coordinate truncation in projected CRS. UTM eastings and northings are six- and seven-digit numbers (e.g. 448236.0, 4651890.0). Stored as float32 they lose ~0.5 m of precision — invisible for yield data, fatal for RTK guidance. Keep projected coordinates in float64 end-to-end; only cast raster values (not coordinates) to float32.
5. Verification & Output Validation
Correctness is confirmed empirically, not by the absence of exceptions. Run this consolidated check after any reprojection or before any cross-layer operation.
import numpy as np
import rasterio
import geopandas as gpd
from pyproj import CRS
def validate_alignment(raster_path: str, vector: gpd.GeoDataFrame) -> dict:
"""Confirm a raster and a vector actually occupy the same space."""
with rasterio.open(raster_path) as src:
assert src.crs is not None, "Raster CRS is None"
assert vector.crs is not None, "Vector CRS is None"
rc, vc = CRS.from_user_input(src.crs), CRS.from_user_input(vector.crs)
assert rc.to_epsg() == vc.to_epsg(), (
f"CRS mismatch: raster EPSG:{rc.to_epsg()} != vector EPSG:{vc.to_epsg()}"
)
assert rc.is_projected, "Work in a projected CRS for area/distance validity"
# Bounding-box overlap is the empirical alignment test
rb = src.bounds
vb = vector.total_bounds # (minx, miny, maxx, maxy)
overlap_x = min(rb.right, vb[2]) - max(rb.left, vb[0])
overlap_y = min(rb.top, vb[3]) - max(rb.bottom, vb[1])
assert overlap_x > 0 and overlap_y > 0, (
"Bounding boxes do not overlap — layers are in the same CRS code but "
"different space; check for a datum-relabel or a bad reprojection"
)
return {
"epsg": rc.to_epsg(),
"projected": rc.is_projected,
"overlap_m2": float(overlap_x * overlap_y),
}
fields = gpd.read_file("field_boundaries.gpkg")
with rasterio.open("ndvi_20240715.tif") as src:
fields = fields.to_crs(src.crs)
print(validate_alignment("ndvi_20240715.tif", fields))
A passing result guarantees three things simultaneously: neither CRS is missing, the codes agree, and the layers physically overlap. A layer pair that agrees on EPSG code but fails the overlap test is the fingerprint of a datum relabel or a reprojection that used the wrong source CRS — precisely the bugs that pass a naive equality check.
For a visual cross-check, load both layers into QGIS with the project CRS forced to the working EPSG, and confirm the vector boundary traces the raster’s field edges. A constant, direction-consistent offset of one to two metres between an RTK layer and its boundary is the visual signature of an unresolved datum shift.
6. Integration with the Pipeline
CRS debugging is not a stage; it is a guard clause you insert at every seam of the broader ag-GIS pipeline.
At ingest. The moment drone or satellite imagery lands, confirm and record its CRS — the ingesting multispectral drone imagery and orthomosaic stitching workflows both assume a known, projected CRS before any radiometric work begins. A missing or geographic CRS at ingest cascades into every downstream error catalogued here.
Before masking and index math. Every rasterio.mask and band-math operation sits behind the Step 4 assert_same_crs guard. The empty-clip trap is the most common way a clean-looking band math and raster algebra result turns out to be all-nodata; clipping rasters to field boundaries with rasterio.mask applies the same guard in context.
Before prescription export. A prescription written in the wrong CRS or with a residual datum shift steers real equipment off-target. Validate the CRS one final time before exporting prescription maps to John Deere GreenStar format, where a 1–2 m offset becomes a physically mis-applied input rate.
Two focused deep-dives. The two most involved failures each have a dedicated guide: fixing rasterio CRS mismatch errors for the raster/vector empty-clip and WarpedVRT reprojection path, and resolving pyproj datum shift warnings for the ballpark-transformation warning and PROJ grid installation.
Frequently Asked Questions
Why is my rasterio mask returning an empty array instead of raising an error?
The raster and the clip geometry are in different coordinate systems, so their coordinates do not overlap and the intersection is empty. Most masking functions treat a non-overlapping geometry as a valid zero-area clip rather than an error. Assert that the geometry CRS equals the raster CRS before masking, and reproject the geometry to match if they differ.
Does the difference between NAD83 and WGS84 matter for farm mapping?
For sub-metre RTK work it does. NAD83 and WGS84 have drifted apart by roughly one to two metres in the continental United States, which exceeds the one to three centimetre accuracy of RTK GPS used for guidance and planting. Treating the two datums as identical shifts every planted row and every prescription cell by that offset, so transform between them explicitly with pyproj instead of relabelling the CRS.
Why do my coordinates come out swapped as latitude and longitude?
EPSG:4326 formally defines its axis order as latitude then longitude, but most file formats and libraries store longitude then latitude. When pyproj follows the authority axis order it returns coordinates in the opposite order to what GeoJSON and shapefiles expect. Construct transformers with always_xy set to True so pyproj always uses longitude then latitude.
Related
- Fixing Rasterio CRS Mismatch Errors — the empty-clip trap and the WarpedVRT reprojection fix for raster/vector CRS disagreement
- Resolving pyproj Datum Shift Warnings — the ballpark-transformation warning, NAD83↔WGS84 grids, and TransformerGroup
- Understanding CRS in Precision Agriculture — datum, projection, and axis-order fundamentals behind these bugs
- How to Convert WGS84 to UTM for Farm Mapping — the correct reprojection into a metric working CRS
- Validating Coordinate Systems for Variable-Rate Maps — pre-export CRS checks that stop misaligned prescriptions reaching the controller