Debugging Thread Safety in Shared PyProj Transformers
A batch reprojection job runs correctly for months, then a worker count is raised from four to sixteen and roughly one feature in four hundred comes out displaced by tens of metres. No exception is raised, every output is a plausible coordinate in the right county, and the job reports success.
Root Cause and Operational Impact
The geospatial stack is a set of Python wrappers over C libraries, and thread safety is a property of the underlying object rather than of the Python API. Nothing at the call site distinguishes an object that is safe to share from one that is not, and the unsafe ones mostly do not raise — they return values.
Two of those four are the dangerous cases, and they are dangerous in the specific way this site keeps returning to: they produce output that passes every plausibility check. A GDAL dataset handle shared across threads corrupts its block cache and returns pixels from the wrong window. A shared Transformer can return a coordinate assembled from two threads’ intermediate state.
The distribution is what makes this hard to catch in testing. Most calls are correct, a few are off by centimetres, and an occasional one is off by tens of metres. A test asserting that the transform completed passes. A test asserting the result is inside the incident bounds passes. Only an exact comparison against a known control point separates them — which is exactly the property-based transform test the CI suite already runs, if it is run under concurrency.
Tiered Resolution Strategy
- Give every worker its own transformer (definitive). Construction is cheap relative to a batch and the correctness is unconditional.
- Never share a dataset handle. Open per worker, or serialise access behind a lock. A handle is not a connection pool.
- Treat prepared geometries as per-worker too. They cache an index on first use behind an API that looks read-only.
- Run the transform property test under concurrency in CI (safe default). A single-threaded assertion cannot detect this class of defect at all.
- Assert on an exact control point, not on bounds. Bounds checks pass on every one of the wrong answers.
The three safe patterns differ only in how visible the ownership is. Thread-local storage is the most convenient and the most easily undone: a later refactor that moves the work onto a different pool, or into a ProcessPoolExecutor, silently changes which objects are shared, and nothing in the diff looks like a concurrency change.
Production Python Implementation
from __future__ import annotations
import logging
import threading
from concurrent.futures import ThreadPoolExecutor
from pyproj import Transformer
logger = logging.getLogger("incidentgis.thread_safety")
_local = threading.local()
def transformer_for(src_epsg: int, dst_epsg: int) -> Transformer:
"""One Transformer per thread per CRS pair.
Construction costs roughly 1.8 ms, paid once per worker. Sharing a single
instance across workers is the defect this exists to prevent, and its
symptom is a wrong coordinate rather than an exception.
"""
cache = getattr(_local, "transformers", None)
if cache is None:
cache = _local.transformers = {}
key = (src_epsg, dst_epsg)
if key not in cache:
cache[key] = Transformer.from_crs(
src_epsg, dst_epsg, always_xy=True,
)
logger.debug("transformer_constructed", extra={
"thread": threading.current_thread().name, "pair": key,
})
return cache[key]
def reproject_batch(points, src_epsg: int, dst_epsg: int, *, workers: int):
"""Reproject in parallel with no shared PROJ state."""
def one(pt):
tf = transformer_for(src_epsg, dst_epsg) # per-thread, never shared
return tf.transform(pt[0], pt[1])
with ThreadPoolExecutor(max_workers=workers,
thread_name_prefix="reproject") as pool:
return list(pool.map(one, points))
# --- the CI check that actually catches this -------------------------------
CONTROL_POINT = (-106.61, 35.08)
EXPECTED = (353470.0, 3883100.0) # metres, EPSG:32613, to 1 cm
TOLERANCE_M = 0.01
def test_transform_is_thread_safe() -> None:
"""Run the known-answer transform under contention and assert exactly.
A bounds assertion passes on every wrong answer this defect produces, so
the check has to compare against a control point to a stated tolerance.
"""
results = reproject_batch([CONTROL_POINT] * 2000, 4326, 32613, workers=16)
for x, y in results:
assert abs(x - EXPECTED[0]) <= TOLERANCE_M, f"easting drifted: {x}"
assert abs(y - EXPECTED[1]) <= TOLERANCE_M, f"northing drifted: {y}"
Validation Checklist
- No
Transformerinstance is reachable from more than one thread. - No dataset handle is shared; workers open their own or take a lock.
- Prepared geometries are constructed per worker, not module-level.
- The transform property test runs with more workers than cores in CI.
- Assertions compare against a control point to a stated tolerance, not against bounds.
- Thread-local ownership is documented at the definition, since a refactor can silently break it.
- Moving work between pool types is treated as a concurrency change in review.
- The test runs enough iterations to hit a rare race — a handful of points will not.
Edge Cases and Gotchas
- A module-level transformer that “has always worked”. It works while the pool has one worker. Raising the worker count is the change that exposes it, and the raise looks harmless in review.
ProcessPoolExecutormasking the bug. Processes get their own memory, so the defect disappears — and returns the moment someone switches back to threads for the pickling cost.- Rare enough to look like bad data. At one in four hundred, the symptom presents as a data-quality problem in the source, and the investigation starts in the wrong place.
always_xyset on one construction path and not another. Per-thread construction multiplies the opportunities to get this wrong; build transformers through one factory, as above.- Locks that serialise the whole batch. Wrapping a shared dataset in a lock is correct and removes the parallelism you added the pool for. Open per worker instead, and accept the file-handle cost.
Frequently Asked Questions
What actually happens when a pyproj Transformer is shared across threads? It returns wrong coordinates rather than raising. Running one control point through a shared transformer on eight workers typically produces six correct results, one displaced by tens of centimetres, and one displaced by tens of metres where a thread picked up another’s intermediate state mid-pipeline. All eight are plausible coordinates in the right region and none raises an exception, so a test asserting the transform completed passes, and so does one asserting the output falls inside the incident bounds.
Why is this so hard to catch in testing? Because the failure rate is low and the failures are plausible. At roughly one feature in four hundred, the symptom presents as a data-quality problem in the source rather than as a concurrency bug, and the investigation starts in the wrong system. The only assertion that separates a correct result from a corrupted one is an exact comparison against a known control point to a stated tolerance — bounds checks pass on every wrong answer — and it has to run under contention with enough iterations to hit the race, which a handful of points will not.
Which geospatial objects are safe to share between threads? Immutable ones. A shapely geometry is safe to read from many threads once constructed. A pyproj Transformer holds internal state and is not documented as thread-safe. A rasterio or GDAL dataset handle is explicitly single-threaded and concurrent reads corrupt its block cache. A prepared geometry caches an index on first use, so despite a read-looking API it holds mutable state and is unsafe. The rule is that anything holding mutable internal state must be per-worker, and the two that matter most fail by returning wrong answers instead of raising.
Related
- Async vs Threaded Python for Geospatial I/O — which work belongs on threads at all, and what belongs on processes instead.
- Writing Property-Based Tests for Coordinate Transforms — the known-answer assertion this check reuses, run under contention.
- Pinning GDAL and PROJ Versions to Avoid Datum Grid Drift — the other way a transform silently returns a different answer.
- Spatial Data Testing & CI Pipelines — where a concurrency-aware transform test belongs in the suite.
Up: Async vs Threaded Python for Geospatial I/O