Caching pyproj Transformers Safely Across Workers
Cache transformers per process on a key covering the CRS pair, always_xy and any area of interest, populate the cache lazily so it is built after the fork rather than inherited through it, and bound its size — a transformer wraps native PROJ state that does not survive a fork, and a key missing always_xy hands back one that swaps latitude and longitude.
This guide sits under CRS Normalization Strategies, within Spatial Payload Routing & Parsing. It is the performance and safety detail behind the normalisation that topic describes.
When to use this pattern
- Every event is reprojected, so construction cost is paid per event rather than per deploy.
- The service runs multiple workers, which is where the fork hazard appears.
- Source CRS values come from payloads rather than from configuration, which is where the unbounded-growth hazard appears.
Construction dominates, and the ratio is not close
Complete runnable implementation
import os
import threading
from dataclasses import dataclass
from pyproj import CRS, Transformer
from pyproj.enums import TransformDirection
# Bound the cache. Source CRS values arriving in payloads are attacker- or
# accident-supplied, and an unbounded dict keyed on them is a memory leak
# with a network interface.
MAX_TRANSFORMERS = 64
@dataclass(frozen=True, slots=True)
class TransformKey:
"""Everything that changes the resulting transformation.
always_xy MUST be here. Two transformers between the same pair with
different axis handling produce coordinates that differ by a swap, so a
key without it hands back one that silently flips lat and lon.
"""
source: str
target: str
always_xy: bool
area_of_interest: tuple[float, float, float, float] | None = None
class TransformerCache:
"""Per-process cache. Never shared across a fork.
A Transformer wraps native PROJ state including file handles and an
internal context. A child process inheriting one may crash, may return
wrong coordinates, or may pass every test and fail under concurrency.
"""
def __init__(self, max_size: int = MAX_TRANSFORMERS) -> None:
self._max_size = max_size
self._lock = threading.Lock()
self._cache: dict[TransformKey, Transformer] = {}
self._owner_pid = os.getpid()
def get(self, key: TransformKey) -> Transformer:
# The guard that turns a silent corruption into an exception. It costs
# one getpid() per call, which is nothing next to a transform.
if os.getpid() != self._owner_pid:
raise RuntimeError(
"transformer cache inherited across a fork — build it lazily "
"inside the worker instead of at import time"
)
with self._lock:
cached = self._cache.get(key)
if cached is not None:
return cached
if len(self._cache) >= self._max_size:
# Simple bound rather than an eviction policy: a service that
# legitimately needs more than 64 CRS pairs should say so in
# configuration rather than discover it at runtime.
raise RuntimeError(
f"transformer cache full ({self._max_size}); "
f"unexpected CRS pair {key.source} -> {key.target}"
)
transformer = Transformer.from_crs(
CRS.from_user_input(key.source),
CRS.from_user_input(key.target),
always_xy=key.always_xy,
area_of_interest=_aoi(key.area_of_interest),
)
self._cache[key] = transformer
return transformer
def warm(self, keys: list[TransformKey]) -> None:
"""Build the transformers this worker will need, before it takes traffic.
Otherwise the first event of each CRS pair pays construction cost, and
after a deploy every worker pays it at once — a latency spike that
looks like a cold cache somewhere else entirely.
"""
for key in keys:
self.get(key)
def _aoi(bounds):
if bounds is None:
return None
from pyproj.aoi import AreaOfInterest
west, south, east, north = bounds
return AreaOfInterest(west, south, east, north)
# One cache per process, populated on first use — which is after any fork.
_CACHE: TransformerCache | None = None
def transformer_for(source: str, target: str = "EPSG:4326",
always_xy: bool = True) -> Transformer:
global _CACHE
if _CACHE is None or _CACHE._owner_pid != os.getpid():
_CACHE = TransformerCache()
return _CACHE.get(TransformKey(source, target, always_xy))
Parameter reference
| Name | Type | Spatial constraint | Default |
|---|---|---|---|
always_xy |
bool |
Must be in the key; omitting it returns a transformer that swaps axes | True |
area_of_interest |
tuple | Changes which pipeline PROJ selects, so it changes results — key on it | None |
MAX_TRANSFORMERS |
int |
Bounds a dict keyed on payload-supplied CRS values | 64 |
_owner_pid |
int |
Fork guard; compared on every get |
— |
warm() |
call | Before the worker takes traffic, not on the first request | — |
| Cache scope | — | Per process. Never a module-level dict populated at import | — |
Gotchas and spatial edge cases
-
always_xymissing from the key is a silent axis swap. EPSG:4326 declares latitude first; most GeoJSON tooling assumes longitude first. Two transformers differing only in that flag return coordinates in opposite orders, and a cache keyed on the CRS pair alone will hand out whichever was built first. The symptom is features in the wrong hemisphere, appearing only for the CRS pairs where both variants are used. -
A payload-supplied source CRS is an unbounded key space.
CRS.from_user_inputaccepts WKT, so a malformed or hostile payload can produce an unlimited number of distinct keys, each costing a database search and a cache slot. Validate the source against an allowlist before it reaches the cache, and treat the “cache full” error as a signal that something upstream changed. -
The area of interest changes the answer, not just the speed. PROJ selects among candidate transformation pipelines partly by which covers the area, and different pipelines differ by metres in some regions. Two transformers between the same pair with different areas of interest are genuinely different transformations, which is why the key includes it.
-
Cached transformers keep grid files open. A worker holding many transformers holds many file descriptors, which interacts badly with a low descriptor limit in a container. Bounding the cache bounds this too, which is a second reason for the limit.
-
Warming must use the same key the request path builds. A warm-up that constructs with
always_xy=Truewhile the request path defaults toFalsepopulates the cache with entries nothing will hit, and the first request still pays construction — with the added confusion that the cache appears full. -
Reprojection is not free of precision cost even when cached. Every transform introduces sub-millimetre differences, so a content hash computed after reprojection differs from one computed before. Round to a fixed precision after transforming, as Event Key Generation for Spatial Data requires.
Verification
import multiprocessing as mp
import pytest
def test_axis_order_is_part_of_the_key():
"""The silent-swap case."""
a = transformer_for("EPSG:4326", "EPSG:25833", always_xy=True)
b = transformer_for("EPSG:4326", "EPSG:25833", always_xy=False)
assert a is not b
x1, y1 = a.transform(13.4049, 52.5200) # lon, lat
x2, y2 = b.transform(52.5200, 13.4049) # lat, lon
assert abs(x1 - x2) < 1e-6 and abs(y1 - y2) < 1e-6
def test_repeated_lookups_reuse_one_transformer():
first = transformer_for("EPSG:25833")
for _ in range(1000):
assert transformer_for("EPSG:25833") is first
def test_cache_is_bounded():
cache = TransformerCache(max_size=2)
cache.get(TransformKey("EPSG:25832", "EPSG:4326", True))
cache.get(TransformKey("EPSG:25833", "EPSG:4326", True))
with pytest.raises(RuntimeError, match="cache full"):
cache.get(TransformKey("EPSG:25834", "EPSG:4326", True))
def _child(cache, queue):
try:
cache.get(TransformKey("EPSG:25833", "EPSG:4326", True))
queue.put("no guard")
except RuntimeError as exc:
queue.put(str(exc))
def test_inherited_cache_raises_rather_than_corrupting():
"""An exception on the first call beats a coordinate audit six months later."""
cache = TransformerCache()
cache.get(TransformKey("EPSG:25833", "EPSG:4326", True))
queue = mp.Queue()
child = mp.get_context("fork").Process(target=_child, args=(cache, queue))
child.start()
child.join()
assert "across a fork" in queue.get()
The first test is the one worth keeping in front of a reviewer: it demonstrates that the two cached transformers are genuinely different by showing that they need their arguments in opposite orders to produce the same point.
Related
- CRS Normalization Strategies — the topic this guide belongs to
- Handling Mixed-CRS Payloads in Python Event Handlers — where the source CRS comes from, and why it must be validated
- Optimizing Async Geometry Parsing with asyncio — the process pool this cache lives inside
- Event Key Generation for Spatial Data — why rounding after reprojection is not optional