Coordinate Reference Systems and Precision Transformations with PyProj: Reference Guide
An engineering reference mapping low-level geodetic calculations, datum conversions, coordinate projection transforms, and UTM validation in Python.
Essential Imports
from pyproj import CRS, Transformer
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info
import pyproj
1. CRS Creation & Inspection
Create CRS from EPSG Code
# Most common way
crs = CRS.from_epsg(4326) # WGS84
print(crs.name) # 'WGS 84'
print(crs.is_geographic) # True
print(crs.is_projected) # False
# UTM example
crs_utm = CRS.from_epsg(32613) # UTM Zone 13N
print(crs_utm.name) # 'WGS 84 / UTM zone 13N'
print(crs_utm.is_projected) # True
Create CRS from WKT or PROJ String
# From WKT (Well-Known Text)
wkt = '''GEOGCS["WGS 84",
DATUM["WGS_1984",
SPHEROID["WGS 84",6378137,298.257223563]],
PRIMEM["Greenwich",0],
UNIT["degree",0.0174532925199433]]'''
crs = CRS.from_wkt(wkt)
# From PROJ string (older format)
crs = CRS.from_proj4("+proj=longlat +datum=WGS84 +no_defs")
Inspect CRS Properties
crs = CRS.from_epsg(32613)
print(crs.name) # 'WGS 84 / UTM zone 13N'
print(crs.area_of_use) # Geographic area where valid
print(crs.datum) # Datum information
print(crs.ellipsoid) # Ellipsoid parameters
print(crs.to_epsg()) # 32613 (if EPSG code exists)
print(crs.to_wkt()) # Full WKT representation
print(crs.to_proj4()) # PROJ string (deprecated but useful)
print(crs.axis_info) # Axis order and units
2. CRS Comparison
Check if Two CRS are Equivalent
crs1 = CRS.from_epsg(4326)
crs2 = CRS.from_epsg(4326)
crs3 = CRS.from_epsg(32613)
print(crs1 == crs2) # True
print(crs1 == crs3) # False
print(crs1.equals(crs2)) # True (more robust comparison)
Check CRS Type
crs = CRS.from_epsg(4326)
if crs.is_geographic:
print("Geographic CRS (lat/lon)")
elif crs.is_projected:
print("Projected CRS (meters/feet)")
elif crs.is_vertical:
print("Vertical CRS (elevation)")
3. Coordinate Transformation (Most Important!)
Basic Point Transformation
# Create transformer: source CRS -> target CRS
transformer = Transformer.from_crs(
"EPSG:4326", # Source: WGS84 (lon, lat)
"EPSG:32613", # Target: UTM Zone 13N (x, y in meters)
always_xy=True # CRITICAL: Use (lon, lat) order, not (lat, lon)
)
# Transform single point
lon, lat = -105.0, 40.0
x, y = transformer.transform(lon, lat)
print(f"UTM: ({x:.2f}, {y:.2f})") # (500000.00, 4427621.26)
# Transform multiple points (arrays)
import numpy as np
lons = np.array([-105.0, -105.1, -105.2])
lats = np.array([40.0, 40.1, 40.2])
xs, ys = transformer.transform(lons, lats)
print(xs) # Array of x coordinates
print(ys) # Array of y coordinates
Reverse Transformation
# UTM -> WGS84
transformer_reverse = Transformer.from_crs(
"EPSG:32613", # Source: UTM
"EPSG:4326", # Target: WGS84
always_xy=True
)
x, y = 500000, 4427621
lon, lat = transformer_reverse.transform(x, y)
print(f"Lon/Lat: ({lon:.6f}, {lat:.6f})") # (-105.0, 40.0)
Why always_xy=True Matters
# WITHOUT always_xy=True (dangerous - uses CRS native order)
transformer_latlon = Transformer.from_crs("EPSG:4326", "EPSG:32613")
# EPSG:4326 native order is (lat, lon), so you'd need:
x, y = transformer_latlon.transform(40.0, -105.0) # (lat, lon) - confusing!
# WITH always_xy=True (recommended - consistent)
transformer_xy = Transformer.from_crs("EPSG:4326", "EPSG:32613", always_xy=True)
x, y = transformer_xy.transform(-105.0, 40.0) # (lon, lat) - clear!
# ALWAYS USE always_xy=True to avoid confusion!
4. Find Appropriate UTM Zone
from pyproj.aoi import AreaOfInterest
from pyproj.database import query_utm_crs_info
# Find UTM zone for a location
lon, lat = -105.0, 40.0
utm_crs_list = query_utm_crs_info(
datum_name="WGS 84",
area_of_interest=AreaOfInterest(
west_lon_degree=lon,
south_lat_degree=lat,
east_lon_degree=lon,
north_lat_degree=lat
)
)
# Get the best match (usually first result)
utm_crs = CRS.from_epsg(utm_crs_list[0].code)
print(f"Best UTM zone: {utm_crs.name}") # WGS 84 / UTM zone 13N
print(f"EPSG code: {utm_crs_list[0].code}") # 32613
5. Geodesic Calculations (Distance/Area on Ellipsoid)
Calculate Distance Between Points
from pyproj import Geod
# Create geodesic calculator for WGS84 ellipsoid
geod = Geod(ellps="WGS84")
# Two points
lon1, lat1 = -105.0, 40.0
lon2, lat2 = -104.9, 40.1
# Calculate forward azimuth, back azimuth, and distance
fwd_azimuth, back_azimuth, distance = geod.inv(lon1, lat1, lon2, lat2)
print(f"Distance: {distance:.2f} meters") # ~15,713 m
print(f"Bearing: {fwd_azimuth:.2f} degrees")
Calculate Point at Distance/Bearing
# Starting point
lon, lat = -105.0, 40.0
# Go 10 km at 45 degrees bearing
distance = 10000 # meters
bearing = 45 # degrees
lon_new, lat_new, back_az = geod.fwd(lon, lat, bearing, distance)
print(f"New point: ({lon_new:.6f}, {lat_new:.6f})")
Calculate Polygon Area (Geodesic)
# Polygon vertices (closed ring)
lons = [-105.0, -104.9, -104.9, -105.0, -105.0]
lats = [40.0, 40.0, 40.1, 40.1, 40.0]
area, perimeter = geod.polygon_area_perimeter(lons, lats)
print(f"Area: {abs(area) / 1e6:.2f} km²")
print(f"Perimeter: {perimeter / 1000:.2f} km")
6. Integration with GeoPandas/Rioxarray
Check CRS Compatibility
import geopandas as gpd
import rioxarray
from pyproj import CRS
# Read data
gdf = gpd.read_file("watersheds.shp")
da = rioxarray.open_rasterio("dem.tif")
# Get PyProj CRS objects
gdf_crs = CRS.from_user_input(gdf.crs)
raster_crs = CRS.from_user_input(da.rio.crs)
# Check if they match
if gdf_crs.equals(raster_crs):
print("CRS match!")
else:
print(f"CRS mismatch: {gdf_crs.name} vs {raster_crs.name}")
Manual Coordinate Transformation (if needed)
# Usually GeoPandas handles this, but for manual control:
gdf = gpd.read_file("points.shp") # EPSG:4326
# Extract coordinates
lons = gdf.geometry.x.values
lats = gdf.geometry.y.values
# Transform using PyProj
transformer = Transformer.from_crs("EPSG:4326", "EPSG:32613", always_xy=True)
xs, ys = transformer.transform(lons, lats)
# Create new GeoDataFrame (or update geometry)
from shapely.geometry import Point
gdf_utm = gdf.copy()
gdf_utm['geometry'] = [Point(x, y) for x, y in zip(xs, ys)]
gdf_utm = gdf_utm.set_crs("EPSG:32613")
7. Common Production & Software Architectural Scenarios
Scenario 1: Validate CRS Match Before Operation
def check_crs_match(crs1, crs2):
"""Check if two CRS are equivalent"""
pyproj_crs1 = CRS.from_user_input(crs1)
pyproj_crs2 = CRS.from_user_input(crs2)
return pyproj_crs1.equals(pyproj_crs2)
# Usage
if not check_crs_match(gdf.crs, da.rio.crs):
print("Reprojecting required!")
gdf = gdf.to_crs(da.rio.crs)
Scenario 2: Transform Point Cloud (Batch)
def transform_points(lons, lats, source_epsg, target_epsg):
"""Transform arrays of coordinates"""
transformer = Transformer.from_crs(
f"EPSG:{source_epsg}",
f"EPSG:{target_epsg}",
always_xy=True
)
return transformer.transform(lons, lats)
# Usage
xs, ys = transform_points(
lons=[-105.0, -105.1, -105.2],
lats=[40.0, 40.1, 40.2],
source_epsg=4326,
target_epsg=32613
)
Scenario 3: Calculate Great Circle Distance
def calculate_distance(lon1, lat1, lon2, lat2):
"""Calculate geodesic distance between two points"""
geod = Geod(ellps="WGS84")
_, _, distance = geod.inv(lon1, lat1, lon2, lat2)
return distance
# Usage
dist_m = calculate_distance(-105.0, 40.0, -104.9, 40.1)
print(f"Distance: {dist_m / 1000:.2f} km")
8. Quick Reference Table
| Operation | Code |
|---|---|
| Create CRS | CRS.from_epsg(4326) |
| Get EPSG code | crs.to_epsg() |
| Check if geographic | crs.is_geographic |
| Check if projected | crs.is_projected |
| Compare CRS | crs1.equals(crs2) |
| Transform point | Transformer.from_crs(src, dst, always_xy=True).transform(x, y) |
| Calculate distance | Geod(ellps="WGS84").inv(lon1, lat1, lon2, lat2) |
| Find UTM zone | query_utm_crs_info(datum_name="WGS 84", area_of_interest=...) |
Common EPSG Codes (Memorize These!)
| EPSG | Description | Units |
|---|---|---|
| 4326 | WGS 84 (GPS) | degrees |
| 3857 | Web Mercator (Google Maps) | meters |
| 32613 | UTM Zone 13N (Colorado) | meters |
| 32633 | UTM Zone 33N (Central Europe) | meters |
| 5070 | NAD83 Albers (CONUS) | meters |
| 2163 | US National Atlas Equal Area | meters |
When to Use PyProj vs GeoPandas/Rioxarray
✅ Use PyProj When:
- Need low-level coordinate transformation
- Working with point arrays (not GeoDataFrames)
- Need geodesic calculations (distance, bearing, area)
- Validating CRS compatibility
- Finding appropriate UTM zone programmatically
✅ Use GeoPandas/Rioxarray When:
- Reprojecting vector/raster data
- Working with spatial data files
- Need automatic CRS handling
- Doing spatial operations (clip, intersect, buffer)
💡 Architectural Integration Pattern:
- Low-Level Precision and Domain Separation: Use
pyprojdirectly when you require highly precise control over low-level coordinate transformations, localized datums, geodesic calculations, or custom projection lookup parameters. - High-Level Pipelines: Leverage the high-level wrappers in
geopandasandrioxarrayfor standard dataset-wide operations (e.g., calling.to_crs()or.rio.reproject()) to maintain code cleanliness and let those engines internally delegate tasks to modern standard PROJ installations efficiently.
Critical Reminders
⚠️ ALWAYS use always_xy=True in Transformer to avoid lat/lon confusion
⚠️ Check CRS match before spatial operations
⚠️ Geographic CRS (EPSG:4326) = degrees, Projected CRS (UTM) = meters
⚠️ UTM zones valid only within ~6° longitude bands
⚠️ Use geodesic calculations (Geod) for distances in geographic coordinates, not simple Euclidean!
Verification Scenarios
- Transform point (-105.27, 40.02) from WGS84 to UTM Zone 13N
- Calculate distance between Denver (-104.99, 39.74) and Boulder (-105.27, 40.02)
- Find UTM zone for coordinates (-122.42, 37.77) (San Francisco)
- Check if CRS match: EPSG:4326 vs EPSG:32613
Reference Implementation
# 1. Transform point
transformer = Transformer.from_crs("EPSG:4326", "EPSG:32613", always_xy=True)
x, y = transformer.transform(-105.27, 40.02)
print(f"UTM: ({x:.2f}, {y:.2f})") # (476959.59, 4430011.94)
# 2. Calculate distance
geod = Geod(ellps="WGS84")
_, _, dist = geod.inv(-104.99, 39.74, -105.27, 40.02)
print(f"Distance: {dist/1000:.2f} km") # 39.25 km
# 3. Find UTM zone
utm_list = query_utm_crs_info(
datum_name="WGS 84",
area_of_interest=AreaOfInterest(-122.42, 37.77, -122.42, 37.77)
)
print(f"UTM Zone: EPSG:{utm_list[0].code}") # EPSG:32610 (Zone 10N)
# 4. Check CRS match
crs1 = CRS.from_epsg(4326)
crs2 = CRS.from_epsg(32613)
print(crs1.equals(crs2)) # False
Architectural Summary:
PyProj is the low-level geodetic engine powering spatial data libraries. Designing robust ETL pipelines involves delegating typical tabular and gridded transformations to high-level structures (geopandas, rioxarray, rasterio) while utilizing pyproj directly for fine-grained geodetic validations, custom transformations, precise geodesic calculations, or region-of-interest coordinate projection mapping across massive distributed scales. 🎯