PyProj vs GDAL for Coordinate Transforms

When a drone survey pipeline needs to reproject a set of ground control points, GNSS fixes, or a finished orthomosaic from one coordinate reference system to another, two libraries dominate the Python ecosystem: PyProj and GDAL. Both are thin bindings over the same PROJ transformation engine, so for a well-defined operation they compute the same numbers to sub-millimetre agreement. The decision between them is therefore not about accuracy — it is about the shape of your data and the ergonomics of the call site. PyProj gives you a lightweight Transformer object tuned for streaming points and NumPy arrays; GDAL gives you osr.CoordinateTransformation for points plus a raster-native path (gdalwarp, rasterio.warp) that reprojects and resamples pixel grids in one pass. This page compares the two on the exact task a survey pipeline runs most often — moving coordinate sets between WGS84 and a projected UTM grid — and shows, with runnable code both ways, where each library is the correct engineering choice. It sits inside the broader ground control point optimization and coordinate sync stage, which anchors imagery to surveyed ground truth.

Audience and prerequisites. This guide targets Python 3.10+ on a 64-bit OS with at least 8 GB RAM. You should understand EPSG codes, UTM zones, the difference between ellipsoidal and orthometric height, and the axis-order trap (some authorities declare latitude before longitude). You do not need to already know either API in depth; the goal is that after reading you can pick the right one per task rather than defaulting to whichever you imported first. The in-depth PyProj implementation lives in coordinate transformation workflows in PyProj, and the GDAL-side CRS contract is covered in managing coordinate reference systems in GDAL; this page is the decision layer that sits above both.

Prerequisites

Install both stacks in one isolated environment so you can benchmark them side by side on identical inputs. The single most important detail is that PyProj and GDAL must resolve to the same PROJ build and grid set, otherwise a datum-grid transform can differ between the two by tens of centimetres and you will chase a phantom bug. Confirm parity with pyproj.proj_version_str and osgeo.osr.GetPROJVersionMajor() before trusting any comparison.

Library Minimum version Install command Role in the comparison
Python 3.10 (system / pyenv) Typed call sites, match statements
pyproj ≥ 3.6 pip install "pyproj>=3.6" Transformer API for point/array transforms
GDAL (osgeo) ≥ 3.6 pip install "gdal==$(gdal-config --version)" osr.CoordinateTransformation, gdalwarp, gdaltransform
numpy ≥ 1.24 pip install "numpy>=1.24" Vectorised point arrays, agreement checks
rasterio ≥ 1.3 pip install "rasterio>=1.3" Pythonic wrapper over GDAL raster warp

GDAL is the awkward install because the Python bindings must match the system GDAL library exactly; on most survey machines a conda environment (conda install -c conda-forge gdal pyproj rasterio) is the least painful route because it pins one shared PROJ across all three packages. Verify grid availability with pyproj.datadir.get_data_dir() and GDAL’s PROJ_DATA (or legacy PROJ_LIB) environment variable pointing at the same directory.

Conceptual architecture

Both libraries are entry points onto the same PROJ core, which owns the datum shifts, grid lookups, and operation selection. What differs is the surface above PROJ and the natural data shape each surface expects. PyProj exposes a Transformer object optimised for feeding it scalars, Python sequences, or NumPy arrays of point coordinates — ideal for GCP tables and GNSS logs. GDAL exposes the same point path through osr.CoordinateTransformation, but its real leverage is the raster path: gdalwarp (and its rasterio.warp wrapper) reprojects a whole pixel grid, resampling with bilinear or cubic interpolation, in a single operation that PyProj cannot do because PyProj has no concept of a raster. The diagram below shows both paths converging on PROJ and diverging again by workflow.

PyProj and GDAL as two API surfaces over a shared PROJ core, diverging by point versus raster workflow A top-to-bottom diagram. At the top left, the PyProj Transformer API box; at the top right, the GDAL osr and gdalwarp and gdaltransform box. Both boxes have arrows pointing down into a shared central box labelled PROJ core, the datum and grid transformation engine that owns operation selection. From the PROJ core, two arrows point down and outward to two workflow boxes: on the left a green-outlined box for point and array streams such as GCP tables, GNSS logs, and tie points, which is PyProj's natural strength; on the right a gold-outlined box for raster warp and command-line batch such as reprojecting an orthomosaic or DSM with resampling, which is GDAL's natural strength. PyProj — Transformer API from_crs · always_xy · transform() GDAL osr.CoordinateTransformation gdalwarp · gdaltransform PROJ core datum shifts · grids · operation selection Point / array streams GCP tables · GNSS logs · tie points PyProj strength Raster warp + CLI batch orthomosaic / DSM reproject + resample GDAL strength

The practical reading of this diagram: if your data is a list of coordinates, both libraries are viable and PyProj is the lighter, more Pythonic call; if your data is a pixel grid that must be reprojected and resampled, only GDAL’s raster path does the job in one step. The sections below implement the identical WGS84→UTM point transform in each library, then extend to arrays and rasters so the trade-off is concrete rather than abstract.

Step 1: The same WGS84→UTM transform in PyProj

The PyProj call is deliberately small. You build one Transformer from the source and target CRS, force always_xy=True so the engine ignores any authority-declared latitude-first axis order, and call transform(x, y) with coordinates in (lon, lat) order. The transformer is expensive to build and cheap to reuse, so construct it once and hold it for the life of the job.

import pyproj

# Build one reusable transformer: WGS84 geographic -> UTM zone 33N.
# always_xy=True guarantees (lon, lat) in and (easting, northing) out,
# regardless of what axis order EPSG:4326 declares.
tf = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)

lon, lat = 15.0, 47.0                       # a fix near Graz, Austria
easting, northing = tf.transform(lon, lat)  # note the (lon, lat) argument order
print(f"PyProj: E={easting:.3f}  N={northing:.3f}")
# PyProj: E=651373.924  N=5206871.187

There is no CRS-string ambiguity to manage, no spatial-reference object to configure, and no axis-order surprise once always_xy=True is set. This is why PyProj is the default for GCP and telemetry tables — the full streaming, chunked, error-isolated version of this call is documented in coordinate transformation workflows in PyProj.

Step 2: The same transform in GDAL

GDAL reaches the identical PROJ operation through osr.SpatialReference and osr.CoordinateTransformation. The critical difference is axis order: since GDAL 3 / PROJ 6, a SpatialReference built from ImportFromEPSG(4326) uses the authority axis order, which for EPSG:4326 is latitude-first. If you feed it (lon, lat) you get a silently swapped result. You must either call SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER) — the direct analogue of PyProj’s always_xy=True — or pass coordinates lat-first. The example makes the mapping explicit.

from osgeo import osr

src = osr.SpatialReference()
src.ImportFromEPSG(4326)                     # WGS84 geographic
tgt = osr.SpatialReference()
tgt.ImportFromEPSG(32633)                    # UTM zone 33N

# THE axis-order line: force (lon, lat) / (x, y) order like PyProj's always_xy.
src.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
tgt.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)

ct = osr.CoordinateTransformation(src, tgt)
easting, northing, _ = ct.TransformPoint(15.0, 47.0)  # (lon, lat) after the mapping
print(f"GDAL:   E={easting:.3f}  N={northing:.3f}")
# GDAL:   E=651373.924  N=5206871.187

The output matches PyProj to the millimetre, because underneath both calls PROJ selected the same transformation pipeline. GDAL’s extra ceremony — two SpatialReference objects and an explicit mapping strategy — is the cost of an API that was designed raster-first and treats point transforms as a secondary feature. For a one-off shell transform, GDAL also ships gdaltransform, a CLI that reads coordinates on stdin: echo "15.0 47.0" | gdaltransform -s_srs EPSG:4326 -t_srs EPSG:32633.

Step 3: Batch and vectorized transforms of a GCP set

Survey pipelines rarely transform a single point; they transform a whole control network at once. Here the ergonomic gap widens. PyProj’s transform() accepts NumPy arrays directly and vectorises the operation in C, so a million-row GNSS log is one call with no Python-level loop. GDAL’s TransformPoints accepts a sequence of (x, y) tuples and returns a list, which is fine for thousands of GCPs but forces a materialised Python list rather than a contiguous NumPy buffer.

import numpy as np
import pyproj
from osgeo import osr

# A small GCP set: longitudes, latitudes (decimal degrees).
lons = np.array([15.00, 15.05, 15.10, 15.15])
lats = np.array([47.00, 47.02, 47.04, 47.06])

# --- PyProj: pass the arrays straight in, vectorised in C ---
tf = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)
e_pyproj, n_pyproj = tf.transform(lons, lats)   # returns two ndarrays

# --- GDAL: build (x, y) tuples, get a list of (x, y, z) back ---
src = osr.SpatialReference(); src.ImportFromEPSG(4326)
tgt = osr.SpatialReference(); tgt.ImportFromEPSG(32633)
src.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
tgt.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
ct = osr.CoordinateTransformation(src, tgt)
pts = ct.TransformPoints(list(zip(lons.tolist(), lats.tolist())))
e_gdal = np.array([p[0] for p in pts])
n_gdal = np.array([p[1] for p in pts])

# The two agree to sub-millimetre because both call the same PROJ pipeline.
assert np.allclose(e_pyproj, e_gdal, atol=1e-3)
assert np.allclose(n_pyproj, n_gdal, atol=1e-3)
print("batch agreement OK:", np.round(e_pyproj, 3))

For a GCP table the PyProj path is shorter, keeps the data in NumPy end to end, and avoids the list-comprehension unpacking that GDAL forces. That is the concrete reason the point-stream lane in the diagram is labelled a PyProj strength: identical math, less friction. If you are already inside a GDAL raster job, however, reusing its CoordinateTransformation avoids importing a second library — parity of results means the choice is purely about which library is already in hand.

Step 4: Reprojecting a raster — GDAL’s exclusive domain

The moment your input is a pixel grid — a finished orthomosaic, a DSM, a DTM — PyProj drops out of contention. PyProj transforms coordinates; it has no machinery to read pixels, resample them onto a new grid, or write a GeoTIFF. GDAL does exactly this through gdalwarp (or the rasterio.warp wrapper, which is GDAL under a Pythonic surface). The transform still runs on the same PROJ engine, but now it is applied per output pixel with an interpolation kernel.

import rasterio
from rasterio.warp import calculate_default_transform, reproject, Resampling

DST_CRS = "EPSG:32633"   # UTM 33N

with rasterio.open("ortho_wgs84.tif") as src:
    # Compute the output grid geometry for the target CRS.
    transform, width, height = calculate_default_transform(
        src.crs, DST_CRS, src.width, src.height, *src.bounds)
    profile = src.profile.copy()
    profile.update(crs=DST_CRS, transform=transform, width=width, height=height)

    with rasterio.open("ortho_utm.tif", "w", **profile) as dst:
        for band in range(1, src.count + 1):
            reproject(
                source=rasterio.band(src, band),
                destination=rasterio.band(dst, band),
                src_transform=src.transform, src_crs=src.crs,
                dst_transform=transform, dst_crs=DST_CRS,
                resampling=Resampling.bilinear,   # cubic for continuous DSMs
            )
print("raster reprojected WGS84 -> UTM 33N")

There is no PyProj equivalent to write here, and that is the point of the comparison: point transforms are a shared capability where PyProj is more ergonomic, while raster reprojection is a GDAL-only capability. A pipeline that reprojects both GCP tables and the orthomosaics they anchor will legitimately use PyProj for the former and GDAL/rasterio for the latter, which is why both appear across the ground control point optimization stage. For the raster-export side of that work, the CRS-tagging discipline is the same one enforced in managing coordinate reference systems in GDAL.

Comparison at a glance

Both libraries wrap PROJ, so the split below is entirely about interface and data shape, never about which produces a “more correct” number for a well-defined operation.

Dimension PyProj GDAL (osr / gdalwarp)
Point transforms Transformer.transform(x, y) — minimal, Pythonic CoordinateTransformation.TransformPoint — verbose, two SRS objects
Array / vectorized Accepts NumPy arrays directly, C-vectorised TransformPoints on a list of tuples; no native ndarray path
Raster reproject + resample Not supported (no raster concept) Native via gdalwarp / rasterio.warp
Axis-order control always_xy=True on the transformer SetAxisMappingStrategy(OAMS_TRADITIONAL_GIS_ORDER) per SRS
Datum / geoid grid handling Compound/3D CRS + bundled grids Compound CRS + shared PROJ grid dir
Determinism / operation choice accuracy=0.0 requests best operation -ct pipeline or CT area of interest
Error signalling out of area Returns inf (guard with math.isfinite) Returns inf or raises depending on build
CLI availability None (library only) gdaltransform, gdalwarp shell tools
Best when GCP/GNSS tables, tie points, telemetry streams Reprojecting orthomosaics, DSMs, mixed point+raster jobs

The one-line heuristic: points → PyProj, pixels → GDAL, and when you already have a GDAL job open, reuse its transform for the points too.

Parameter deep-dive

These are the settings that actually change results or throughput. Tune against a known-good reference, never by guesswork.

Parameter Library Default Valid range Effect
always_xy PyProj False True / False True forces (lon, lat) order — set it or expect swapped axes.
accuracy PyProj None ≥ 0.0 0.0 requests the most accurate operation; higher permits faster paths.
OAMS_TRADITIONAL_GIS_ORDER GDAL authority order traditional / authority Traditional = (lon, lat); authority may be lat-first and cause swaps.
resampling GDAL raster nearest nearest/bilinear/cubic Bilinear for orthos, cubic for smooth DSMs; nearest for categorical rasters.
PROJ_DATA env both bundled dir valid path Must point at one shared grid set so both libraries agree on datum shifts.
area of interest both none bbox Constrains operation selection so PROJ picks the grid valid for your survey.

Verification and output inspection

The correct verification for a comparison is twofold: each library must round-trip its own transform back to the input, and the two libraries must agree with each other on the forward transform. The agreement check is the one that catches a mismatched PROJ grid between the two installs — the single most damaging silent failure in a mixed pipeline.

import math
import numpy as np
import pyproj
from osgeo import osr

# --- forward transforms ---
tf = pyproj.Transformer.from_crs("EPSG:4326", "EPSG:32633", always_xy=True)
e_p, n_p = tf.transform(15.0, 47.0)

src = osr.SpatialReference(); src.ImportFromEPSG(4326)
tgt = osr.SpatialReference(); tgt.ImportFromEPSG(32633)
src.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
tgt.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
e_g, n_g, _ = osr.CoordinateTransformation(src, tgt).TransformPoint(15.0, 47.0)

# 1. The two libraries agree (same PROJ pipeline, same grids).
assert math.isclose(e_p, e_g, abs_tol=1e-3), "PyProj/GDAL easting disagree — check PROJ parity"
assert math.isclose(n_p, n_g, abs_tol=1e-3), "PyProj/GDAL northing disagree — check PROJ parity"

# 2. Result lands in a sane UTM band (a wrong zone fails here).
assert 100_000 <= e_p <= 900_000, f"easting outside UTM band: {e_p:.1f}"

# 3. PyProj round-trip reproduces the input within 1e-7 degrees.
inv = pyproj.Transformer.from_crs("EPSG:32633", "EPSG:4326", always_xy=True)
lon_back, lat_back = inv.transform(e_p, n_p)
assert abs(lon_back - 15.0) < 1e-7 and abs(lat_back - 47.0) < 1e-7, "round-trip drift"
print("verified — agreement and round-trip within tolerance")

If the agreement assertions fire while each library round-trips cleanly on its own, you have two different PROJ grid sets installed, not a bug in either call. Pin the versions, point both at one PROJ_DATA directory, and re-run. This is exactly the parity discipline that keeps a build server and a field laptop from producing different survey coordinates for identical inputs.

Troubleshooting

PyProj and GDAL return different easting/northing for the same point. Both wrap PROJ, so identical inputs must agree unless the two installs resolve to different PROJ builds or grid data. Print pyproj.proj_version_str and osgeo.osr.GetPROJVersionMajor(), confirm both point at one PROJ_DATA directory, and prefer a single conda-forge environment that pins one shared PROJ across pyproj, GDAL, and rasterio.

My GDAL transform swaps easting and northing but the PyProj one is correct. GDAL 3 honours the authority axis order, and EPSG:4326 declares latitude first, so feeding (lon, lat) yields a swap. Call SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER) on both spatial references — it is the direct analogue of PyProj’s always_xy=True — or pass coordinates latitude-first.

I need to reproject an orthomosaic and PyProj has no function for it. That is expected: PyProj transforms coordinates only and has no raster machinery. Use gdalwarp or rasterio.warp.reproject, which apply the same PROJ transform per pixel and resample onto the new grid. Reserve PyProj for the point and GCP tables in the same pipeline.

Heights change by tens of metres after transforming in either library. You are mixing ellipsoidal and orthometric height. Neither library applies a geoid shift unless the target is a compound or 3D CRS and the geoid grid is installed. Build a compound target CRS and confirm the grid is present via pyproj.datadir.get_data_dir() and the matching GDAL PROJ_DATA.

TransformPoints is slow on a large GCP array in GDAL. GDAL returns a Python list of tuples rather than a NumPy buffer, so large sets pay a materialisation cost. For array-heavy work pass the arrays straight into PyProj’s transform(), which vectorises in C, and keep GDAL for the raster stage.

A point outside the survey area returns inf instead of an error. PROJ signals an out-of-area result with a non-finite value. PyProj always returns inf; GDAL may return inf or raise depending on the build. Guard every result with math.isfinite() and route non-finite rows to quarantine rather than into the solver.

Ground Control Point Optimization & Coordinate Sync