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

Almost all of the time is setup Ten thousand events are reprojected from a local grid to EPSG:4326, each carrying a geometry of about forty vertices. Without caching, each event constructs a Transformer — a PROJ database search, a ranking of candidate pipelines by accuracy, and possibly a grid file read — costing on the order of two milliseconds, and then performs the transformation itself in tens of microseconds. Construction is therefore over ninety-five per cent of the elapsed time, and the profile is dominated by database lookups rather than by anything geometric. With a per-process cache, construction happens once per distinct CRS pair for the lifetime of the worker, so ten thousand events pay it once and the remaining time is the transformation itself. The improvement is not a tuning gain of a few per cent but an order of magnitude, and it changes what the reprojection stage is: from an I/O-bound lookup service into a CPU-bound numeric one. 10 000 events · ~40 vertices each · local grid → EPSG:4326 no cache Transformer construction — PROJ database search, pipeline ranking, grid file reads ≈ 2 ms per event constructing · tens of microseconds transforming · over 95% is setup per-process cache one construction per distinct CRS pair, for the life of the worker This is not a few per cent — it changes what the stage is Uncached, reprojection is an I/O-bound lookup service whose latency depends on disk and on how many candidate pipelines PROJ has to rank. Cached, it is a CPU-bound numeric one whose latency depends on vertex count, which is what you can plan against.
Figure 1. Uncached, the reprojection stage's latency depends on the PROJ database rather than on the geometry — which is why it does not scale with anything you can measure at ingest.

Complete runnable implementation

python
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))
Where the cache is built decides whether it is safe Two start-up sequences are compared for a service running four forked workers. In the first, the transformer cache is populated at import time in the parent process, before the fork. Each of the four children inherits the parent's transformers, which wrap native PROJ contexts and open file handles that were never designed to be duplicated. The consequences are not deterministic: a child may segfault immediately, may produce coordinates that are subtly wrong, or may behave perfectly until two workers use the same inherited context concurrently. The last of those is the dangerous one, because it survives testing. In the second sequence the parent imports the module but builds nothing, the fork happens, and each child populates its own cache on first use. Every worker owns native state it created, nothing is shared, and the cost is that each worker pays construction once per CRS pair — which is what the warm-up call exists to move out of the first request. The process-id guard turns the first pattern from a silent corruption into an exception on the first call. built at import time, then forked parent builds cache native PROJ contexts fork worker 1 worker 2 worker 3 worker 4 inherited file handles and contexts, duplicated four ways may segfault · may return wrong coordinates · may work until two workers use one context at once the third is the dangerous one: it survives testing built lazily, after the fork parent imports only builds nothing own cache own cache own cache own cache every worker owns state it created · nothing shared cost: construction once per CRS pair — which warm() moves out of the first request The process-id guard costs one getpid() per call and turns the top pattern from a silent corruption into an exception on the first call — which is the difference between a bug found in a smoke test and one found in a coordinate audit.
Figure 2. The failure that survives testing is the one to design against: an inherited context that works fine until two workers touch it at the same moment.

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

  1. always_xy missing 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.

  2. A payload-supplied source CRS is an unbounded key space. CRS.from_user_input accepts 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.

  3. 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.

  4. 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.

  5. Warming must use the same key the request path builds. A warm-up that constructs with always_xy=True while the request path defaults to False populates the cache with entries nothing will hit, and the first request still pays construction — with the added confusion that the cache appears full.

  6. 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.

A cache keyed on payload input is a memory leak with a network interface Source CRS values arrive in payloads, and pyproj accepts WKT as well as authority codes. A producer emitting semantically identical WKT with differing whitespace, or a malformed value, therefore produces a distinct cache key for every variation — each costing a PROJ database search on insertion and a permanent slot thereafter. An unbounded dictionary keyed on that input grows for as long as the variations keep arriving, and because each entry also holds native PROJ state and open grid-file handles, the process runs out of descriptors before it runs out of memory. Bounding the cache converts an unbounded leak into a loud error naming the unexpected CRS pair, which is diagnostic rather than fatal: the error says which value was unexpected, and the fix is an allowlist at the edge rather than a larger cache. The bound is therefore a detection mechanism as much as a limit. source CRS arriving in payloads · pyproj accepts WKT as well as codes unbounded cache "PROJCS[...]" "PROJCS[ ...]" "PROJCS[...] " whitespace variants are distinct keys each costs a database search, then a permanent slot descriptors run out before memory does — each entry holds grid files open bounded cache the 65th distinct pair raises, naming the value an unbounded leak becomes a loud, diagnostic error the fix is an allowlist at the edge, not a larger cache the bound is a detection mechanism as much as a limit Which is why the error message carries the CRS pair: the useful information is what arrived, not that the cache was full.
Figure 3. Sizing the bound generously and treating the error as a signal beats raising it every time it fires — the error names an upstream change worth knowing about.

Verification

python
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.