How to Convert WGS84 to UTM for Farm Mapping
TL;DR: Instantiate a pyproj.Transformer with always_xy=True, auto-derive the correct UTM EPSG code from your dataset’s centroid longitude, then call transformer.transform(lon, lat) to get meter-based Easting/Northing arrays. For GeoDataFrames, gdf.to_crs(epsg=utm_epsg) is all you need once the source CRS is declared.
Why This Conversion Arises in Ag-GIS Workflows
WGS84 (EPSG:4326) is the native output of every GPS receiver, RTK base station, and satellite positioning chipset used in precision agriculture. It is ideal for data storage and global interoperability. It is entirely unsuitable for any calculation that involves distance, area, or spatial interpolation at field scale.
The root problem is that WGS84 stores positions as angular degrees. At 45°N latitude, one degree of longitude represents roughly 78 km of ground distance; at 60°N it shrinks to 55 km. Any Euclidean calculation — Kriging variogram fitting, polygon area, buffer radius, equipment row spacing — run on degree-based coordinates will produce results that are wrong by a factor that varies across the field. At mid-latitudes, area errors commonly reach 20–30% compared to true ground measurements.
UTM (Universal Transverse Mercator) solves this by dividing the globe into 60 six-degree longitudinal zones, each projected with a Transverse Mercator projection that caps scale distortion at ≤0.04% across the zone width. Field-scale operations stay well within a single zone, so coordinates in meters are effectively Cartesian — valid inputs for Euclidean geometry, spatial statistics, and equipment guidance systems. The full context of how projection choices propagate through a farm data pipeline is covered in Understanding CRS in Precision Agriculture.
Prerequisites
This page assumes you have the parent cluster’s environment already set up. The only additional requirement is:
pyproj>=3.4— forTransformer.from_crs()and explicitCRSobjectsnumpy>=1.24— for vectorised masked-array operations on GPS log arrays- Input data: longitude/latitude arrays in WGS84 (EPSG:4326), or a
GeoDataFramewithcrs="EPSG:4326"already declared
If your data arrives as raw equipment CSV logs without a declared CRS, declare it explicitly with gdf.set_crs("EPSG:4326", inplace=True) before any transformation. Never guess or inherit CRS from file location.
Step-by-Step Conversion
Step 1 — Derive the target UTM EPSG code
UTM zones are six degrees wide. Zone number = floor((lon + 180) / 6) + 1. Northern hemisphere zones use EPSG 326xx; southern hemisphere use 327xx. Compute this from the dataset centroid so the entire batch lands in one zone:
import numpy as np
from pyproj import Transformer, CRS
def get_utm_epsg(lon: float, lat: float) -> int:
"""Return the UTM EPSG code for the given WGS84 centroid."""
zone = int(np.floor((lon + 180) / 6) + 1)
return 32600 + zone if lat >= 0 else 32700 + zone
Step 2 — Build the Transformer and convert raw arrays
Use this pattern when you have bare NumPy arrays from equipment GPS logs, drone ground control points (GCPs), or soil-probe coordinates that are not yet in a GeoDataFrame:
from typing import Tuple
def convert_wgs84_to_utm(
lon: np.ndarray,
lat: np.ndarray,
) -> Tuple[np.ndarray, np.ndarray, int]:
"""
Batch-convert WGS84 lon/lat arrays to UTM Easting/Northing.
Returns (easting, northing, utm_epsg).
Coordinates that are NaN or infinite are passed through as NaN.
"""
if lon.size == 0:
return np.array([]), np.array([]), 0
valid = np.isfinite(lon) & np.isfinite(lat)
if not valid.any():
return np.full_like(lon, np.nan), np.full_like(lat, np.nan), 0
centroid_lon = float(np.nanmean(lon[valid]))
centroid_lat = float(np.nanmean(lat[valid]))
utm_epsg = get_utm_epsg(centroid_lon, centroid_lat)
# always_xy=True enforces longitude-first axis ordering regardless of
# what the CRS authority record says — omitting this is the single most
# common cause of silent 90-degree coordinate swaps.
transformer = Transformer.from_crs(
CRS.from_epsg(4326),
CRS.from_epsg(utm_epsg),
always_xy=True,
)
easting = np.full_like(lon, np.nan, dtype=float)
northing = np.full_like(lat, np.nan, dtype=float)
easting[valid], northing[valid] = transformer.transform(lon[valid], lat[valid])
return easting, northing, utm_epsg
Step 3 — Convert a GeoDataFrame directly
For vector data already in a GeoDataFrame — field boundaries, prescription zones, soil-sample points — use to_crs(). The source CRS must be declared:
import geopandas as gpd
def reproject_farm_gdf(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Reproject a WGS84 GeoDataFrame to the appropriate UTM zone."""
assert gdf.crs is not None, "Source CRS must be declared; call set_crs() first."
assert gdf.crs.to_epsg() == 4326, f"Expected EPSG:4326, got {gdf.crs.to_epsg()}"
# Derive UTM zone from the layer centroid
centroid = gdf.dissolve().centroid.iloc[0]
utm_epsg = get_utm_epsg(centroid.x, centroid.y)
gdf_utm = gdf.to_crs(epsg=utm_epsg)
# Inline verification: check Easting bounds for UTM validity
x_coords = gdf_utm.geometry.centroid.x
assert x_coords.between(160_000, 834_000).all(), (
f"Easting out of UTM range: min={x_coords.min():.0f}, max={x_coords.max():.0f}. "
"Check for axis swap or wrong zone."
)
return gdf_utm
Inline verification: after the assert passes, spot-check one known point:
# Verification: known GCP in Iowa (UTM Zone 15N, EPSG:32615)
import numpy as np
e, n, epsg = convert_wgs84_to_utm(
np.array([-93.620]), np.array([42.031])
)
print(f"EPSG:{epsg} E={e[0]:.1f} N={n[0]:.1f}")
# Expected: EPSG:32615 E≈456000 N≈4651000
assert epsg == 32615
assert 400_000 < e[0] < 500_000
assert 4_600_000 < n[0] < 4_700_000
Gotchas and Edge Cases
-
Omitting
always_xy=Trueswaps lat and lon silently. The default axis order for EPSG:4326 in pyproj follows the ISO standard (latitude first), which is the opposite of how most GIS engineers pass arrays. The result is a projection with inverted inputs — coordinates land hundreds of kilometres away with no exception raised. Always passalways_xy=True. -
Zone boundary crossings cause discontinuities. If a single farm straddles two UTM zones (rare, but possible for large operations near 6°-multiple longitudes), projecting everything into one zone introduces increasing distortion towards the far edge. The fix: define a custom Transverse Mercator using
CRS.from_dict({"proj": "tmerc", "lon_0": centroid_lon, "datum": "WGS84", "units": "m"})centred on the farm centroid. -
GeoJSON round-trips will strip your UTM CRS. The GeoJSON specification (RFC 7946) mandates WGS84. If you write a UTM GeoDataFrame to
.geojson, the CRS is silently lost or the file becomes non-standard. Use GeoPackage (.gpkg) or GeoParquet for all intermediate and analysis outputs; convert back to WGS84 only for final web export. -
Equipment CSV logs often have lat/lon column order reversed. Some John Deere Greenstar and Trimble AgGPS exports write
latitude, longitudewhile others writelongitude, latitude. Always inspect the header and verify that longitude is in the range −180 to 180 before passing toconvert_wgs84_to_utm.
This guide is part of Understanding CRS in Precision Agriculture — see there for the full pipeline context covering raster reprojection, datum grid validation, and CRS standardisation across mixed vector/raster inputs.
Related
- Validating Coordinate Systems for Variable Rate Maps — programmatic CRS consistency checks across shapefiles, GeoTIFFs, and prescription grids before field deployment
- Field Boundary Extraction with GeoPandas — converting raster segmentation masks and GPS traces to topology-valid field polygons, including CRS alignment before vectorisation
- Ingesting Multispectral Drone Imagery — reading MicaSense RedEdge-MX and DJI P4 Multispectral tiles into rasterio, including CRS standardisation before band-math workflows
- Understanding CRS in Precision Agriculture — parent guide covering the full CRS architecture for ag-data pipelines