pyproj vs GDAL for batch reprojection
Both libraries call PROJ, so a correctly configured pyproj transform and a correctly configured ogr2ogr transform produce the same coordinates to the last representable digit. The comparison is therefore not about accuracy. It is about throughput on a county-sized layer, whether the reprojection happens inside your process or as a subprocess, how each handles a missing grid-shift file, and which one lets you assert on the result. This guide measures those on a 214,000-parcel layer moving from a state plane CRS to EPSG:4326, extending CRS alignment strategies.
Diagnosis: which question are you actually asking? jump to heading
Two distinct problems get labelled “batch reprojection” and they have different answers.
Reprojecting a file. A shapefile arrives in EPSG:2926 and you want a GeoPackage in EPSG:4326. Nothing else happens to the data. This is a file-format operation and GDAL’s command-line tools do it in one line, faster than anything you can write.
Reprojecting inside a transformation. Parcels are being parsed, validated, normalised and upserted, and reprojection is one step among several. The geometries are already Python objects; writing them to a file so GDAL can read them back is pure overhead. This is pyproj’s case.
Choosing wrong costs about 4× either way, and the failure is quiet — a pipeline that shells out to ogr2ogr per batch spends most of its time on process startup and I/O.
Head-to-head jump to heading
| pyproj | GDAL (ogr2ogr / Python bindings) |
|
|---|---|---|
| PROJ version | whatever the wheel bundles | whatever the system GDAL links |
| Reprojects | coordinate arrays, in process | files and layers |
| Best throughput shape | one Transformer, vectorised arrays |
one process, whole layer |
| Grid shift missing | raises or falls back per only_best |
warns, may silently use a lower-accuracy path |
| Format conversion | none — bring your own I/O | 80+ drivers |
| Geometry repair while reprojecting | no | -makevalid, -wrapdateline |
| Install weight | a wheel with bundled PROJ | a system library, or a large wheel |
| Assertable in tests | directly | via subprocess output or bindings |
Step-by-step implementation jump to heading
1. Build the transformer once, and transform arrays jump to heading
The single largest pyproj performance mistake is constructing a Transformer per geometry. Construction parses the CRS definitions and selects an operation; reuse is what makes it fast.
import numpy as np
from pyproj import CRS, Transformer
SOURCE = CRS.from_epsg(2926) # NAD83(HARN) / Washington North (ftUS)
TARGET = CRS.from_epsg(4326)
# always_xy keeps the argument order as (x, y) / (lon, lat) regardless of what the
# authority declares. Omitting it is the classic silent axis-order bug.
TF = Transformer.from_crs(SOURCE, TARGET, always_xy=True)
def reproject_rings(rings: list[np.ndarray]) -> list[np.ndarray]:
"""Transform every ring in one vectorised call.
Concatenating first matters: one call over 400k points is roughly 30x faster
than 8k calls over 50 points, because the per-call overhead dominates at that
size."""
if not rings:
return []
lengths = [len(r) for r in rings]
flat = np.concatenate(rings)
lon, lat = TF.transform(flat[:, 0], flat[:, 1])
out = np.column_stack([lon, lat])
bounds = np.cumsum([0] + lengths)
return [out[bounds[i]:bounds[i + 1]] for i in range(len(rings))]
2. Make the grid-shift requirement explicit jump to heading
A datum shift that needs a grid file will silently fall back to a lower-accuracy path if the grid is absent — a metre-scale error that never raises. State the requirement instead of hoping.
from pyproj.transformer import TransformerGroup
def assert_best_transform_available(source, target):
"""Fail at startup if the highest-accuracy operation is unavailable.
An off-by-a-metre reprojection is worse than a crash: it passes every geometry
check and puts parcel boundaries a metre from where the county has them."""
group = TransformerGroup(source, target, always_xy=True)
if not group.best_available:
missing = [g.name for g in group.unavailable_operations]
raise RuntimeError(
f"best transform {source.to_epsg()}->{target.to_epsg()} unavailable; "
f"missing grids for: {missing}. Run `projsync --source-id us_noaa` "
f"or set PROJ_NETWORK=ON."
)
return group.transformers[0]
3. Use GDAL when the unit of work is a file jump to heading
# One process, whole layer. -t_srs picks the operation; -makevalid repairs rings that
# only become invalid after the transform, which happens on long thin parcels.
ogr2ogr \
-f GPKG parcels_4326.gpkg parcels_2926.shp \
-s_srs EPSG:2926 -t_srs EPSG:4326 \
-makevalid \
-nlt PROMOTE_TO_MULTI \
-lco SPATIAL_INDEX=YES \
--config OGR_ENABLE_PARTIAL_REPROJECTION YES
-s_srs is explicit on purpose. Shapefile .prj files from municipal portals are frequently wrong or absent, and letting GDAL guess is how a layer ends up reprojected from the wrong source CRS — which produces coordinates in the right numeric range and the wrong place.
4. Decide once, at the pipeline boundary jump to heading
def reproject(source_path, target_path, *, in_pipeline: bool):
"""Files at the edges, arrays in the middle.
The costly mistake is mixing them — a per-batch ogr2ogr subprocess inside an
ingestion loop pays process startup and two format conversions per batch."""
if in_pipeline:
return [reproject_rings(g) for g in read_geometries(source_path)]
return subprocess.run(
["ogr2ogr", "-f", "GPKG", target_path, source_path,
"-s_srs", declared_crs(source_path), "-t_srs", "EPSG:4326", "-makevalid"],
check=True, capture_output=True, text=True,
)
Verification & testing jump to heading
The test that matters is not “does it reproject” but “do both paths agree, and does a missing grid fail loudly.”
def test_both_paths_agree_within_a_millimetre(tmp_path, county_fixture):
"""The libraries share PROJ, so disagreement means a configuration difference —
usually axis order or an implicit source CRS — not a numerical one."""
via_pyproj = reproject_rings(read_geometries(county_fixture))
out = tmp_path / "gdal.gpkg"
subprocess.run(["ogr2ogr", "-f", "GPKG", str(out), str(county_fixture),
"-s_srs", "EPSG:2926", "-t_srs", "EPSG:4326"], check=True)
via_gdal = read_geometries(out)
for a, b in zip(via_pyproj, via_gdal):
# ~1e-8 degrees is about a millimetre at this latitude.
np.testing.assert_allclose(a, b, atol=1e-8)
def test_axis_order_is_pinned():
"""Without always_xy, EPSG:4326 yields (lat, lon) and every downstream bbox
filter silently matches nothing."""
tf = Transformer.from_crs(2926, 4326, always_xy=True)
lon, lat = tf.transform(1_270_000, 230_000)
assert -125 < lon < -116 and 45 < lat < 49
def test_missing_grid_raises_rather_than_degrading(monkeypatch):
monkeypatch.setenv("PROJ_NETWORK", "OFF")
monkeypatch.setenv("PROJ_DATA", str(EMPTY_GRID_DIR))
with pytest.raises(RuntimeError, match="missing grids"):
assert_best_transform_available(CRS.from_epsg(4267), CRS.from_epsg(4326))
def test_round_trip_is_stable_over_repeated_transforms(county_fixture):
"""A reprojection applied and reversed should return the original to within
grid accuracy. Drift here means an operation without a defined inverse."""
fwd = Transformer.from_crs(2926, 4326, always_xy=True)
rev = Transformer.from_crs(4326, 2926, always_xy=True)
for ring in read_geometries(county_fixture)[:200]:
lon, lat = fwd.transform(ring[:, 0], ring[:, 1])
x, y = rev.transform(lon, lat)
np.testing.assert_allclose(np.column_stack([x, y]), ring, atol=1e-3) # 1 mm in ftUS
Failure recovery jump to heading
A layer reprojected from the wrong source CRS. The coordinates land in a plausible range, so nothing fails. Detect it by checking the reprojected envelope against the jurisdiction’s known bounds, and recover by reprojecting from the original file with an explicit -s_srs — never by reprojecting the already-wrong output, which compounds the error.
Rings that became invalid after the transform. Long thin parcels near a projection’s edge can self-intersect at the new precision. -makevalid in GDAL, shapely.make_valid after reproject_rings in Python; either way assert validity after the transform rather than before.
Two environments producing different coordinates. Almost always different PROJ data versions rather than different code. Pin the PROJ version and the grid set in the container, and log pyproj.proj_version_str and pyproj.datadir.get_data_dir() at startup so the difference is visible in a run’s own output.
Frequently asked questions jump to heading
Which is faster on a county-sized layer?
For a whole-file conversion, GDAL — it does the reprojection and the format write in one pass, and there is nothing to beat about that. For geometries already in memory as arrays, pyproj with a reused Transformer and vectorised calls, because GDAL would require writing to a file and reading it back. The two numbers are not comparable; they answer different questions.
Do I need both installed?
Usually yes, and not for reprojection. GDAL is already in the stack for reading shapefiles, GeoPackages and GeoJSON, and pyproj arrives with pretty much any Python geospatial dependency. Having both is normal; the risk is that they link different PROJ versions, which is worth checking at startup rather than discovering from a coordinate discrepancy.
Is always_xy=True always right?
For this kind of work, yes — set it everywhere and stay in (x, y) / (lon, lat) order throughout. The authority-declared order for EPSG:4326 is latitude-first, and honouring it means every call site has to remember which convention applies. Pinning the order in one place and asserting it in a test removes an entire class of silent bug.
What about GeoPandas to_crs?
It is pyproj underneath, applied per geometry column, and it is the right choice when the data is already a GeoDataFrame — the vectorisation is handled for you. The caveats are the same: build the transform once (GeoPandas does), pin the axis order (it does), and check that the best operation is available (it does not — that check is still yours).