Caching PROJ Grids for Offline Processing

The same survey processed on two machines produces coordinates eleven centimetres apart. Both ran the same code, the same library versions and the same input. One machine had downloaded a transformation grid at some point; the other had not, and PROJ quietly used a lower-accuracy path.

Modern PROJ can fetch grids on demand over the network, which is convenient for interactive work and corrosive for a processing pipeline: it makes a numerical result depend on network state. This page covers taking control of that — determining which grids a project needs, fetching them once, pinning them into the processing environment, and proving afterwards that the expected ones were used. It supports the reproducibility discipline in geoid models and vertical datum automation.

Why on-demand fetching is a problem for a pipeline

Three properties make it unsuitable for production, and none of them is a criticism of the feature itself.

The result depends on network state. A machine behind a proxy, a worker on an isolated network, or a transient outage produces a different answer rather than an error.

The cache is invisible. Grids land in a user cache directory whose contents differ between machines and between users on one machine, so “it works on my machine” is literally true and unhelpful.

Grids are revised. A national agency publishing an improved grid changes the answer for everybody who fetches afterwards, which is an accuracy improvement and a reproducibility failure at the same time.

The same pipeline on three machines with different grid availability Three workers running identical code on identical input. The first has the grid cached and produces the correct coordinate. The second has no grid and no network, so PROJ falls back to a lower-accuracy operation and produces a coordinate eleven centimetres away. The third has no cached grid but does have network access, so it fetches a newer revision of the grid and produces a coordinate three centimetres from the first. A note records that none of the three reports an error and all three appear to have succeeded. grid cached best transformation used reference coordinate exit status: success no grid, no network silent fallback 11 cm away exit status: success no grid, network on fetches a newer revision 3 cm away exit status: success Three answers, three successes, no errors anywhere. Which is why the grid set belongs in the image rather than in a cache. The third machine is arguably the most accurate, and it still breaks the comparison.

Figure 1 — The failure is not that a machine is wrong; it is that they disagree and none of them says so.

Determining which grids a project needs

A project needs the grids for the transformations it performs, which is a short list that can be enumerated from the CRS pairs in use.

from pyproj import CRS
from pyproj.transformer import TransformerGroup


def required_grids(crs_pairs: list[tuple[str, str]]) -> dict:
    """Enumerate every grid the project's transformations can use.

    Both available and unavailable grids are listed, because the point is to
    fetch the missing ones rather than to work around them.
    """
    needed: dict[str, dict] = {}
    for source_def, target_def in crs_pairs:
        source = CRS.from_user_input(source_def)
        target = CRS.from_user_input(target_def)
        group = TransformerGroup(source, target, always_xy=True)
        for operation in list(group.transformers) + list(group.unavailable_operations):
            for grid in getattr(operation, "grids", []):
                needed[grid.short_name] = {
                    "full_name": grid.full_name,
                    "url": grid.url,
                    "available": grid.available,
                    "direct_download": grid.direct_download,
                    "open_license": grid.open_license,
                }
    missing = {k: v for k, v in needed.items() if not v["available"]}
    return {"grids": needed, "missing": missing,
            "restricted": {k: v for k, v in needed.items() if not v["open_license"]}}

The restricted list matters. Some national grids are not openly licensed and cannot be redistributed inside an image, so a project depending on them needs them obtained and installed separately — which is a licensing conversation rather than a technical one, and better had at the start of a project than at delivery.

Fetching and pinning them

import hashlib
import shutil
import subprocess
from pathlib import Path


def sync_grids(target_dir: str, *, area_of_interest: dict | None = None) -> dict:
    """Download the grids a project needs into a directory under its control.

    projsync fetches into the PROJ data directory; copying into a
    project-controlled directory afterwards is what makes the set
    reproducible, because a user cache is neither versioned nor shared.
    """
    cmd = ["projsync", "--target-dir", str(target_dir), "--all"]
    if area_of_interest:
        cmd = ["projsync", "--target-dir", str(target_dir),
               "--bbox", "{west},{south},{east},{north}".format(**area_of_interest)]
    result = subprocess.run(cmd, capture_output=True, text=True)
    if result.returncode != 0:
        raise RuntimeError(f"projsync failed: {result.stderr[-400:]}")

    files = sorted(p for p in Path(target_dir).rglob("*") if p.is_file())
    digest = hashlib.sha256()
    for path in files:
        digest.update(path.name.encode())
        digest.update(str(path.stat().st_size).encode())

    return {"directory": str(target_dir), "files": len(files),
            "inventory_digest": digest.hexdigest()[:16],
            "total_mb": round(sum(p.stat().st_size for p in files) / 1e6, 1)}

Using a bounding box rather than fetching everything is worth doing: the complete grid set is tens of gigabytes and a project needs the few hundred megabytes covering its own region.

Disabling the network in the processing environment

import os


def lock_proj_environment(grid_dir: str) -> dict:
    """Point PROJ at a fixed grid directory and forbid network access.

    Setting both is necessary. PROJ_DATA alone leaves network fetching
    enabled, so a missing grid is still fetched and the directory is not the
    complete story of what was used.
    """
    os.environ["PROJ_DATA"] = str(grid_dir)
    os.environ["PROJ_NETWORK"] = "OFF"
    os.environ.pop("PROJ_LIB", None)          # legacy variable; can override PROJ_DATA

    import pyproj
    pyproj.network.set_network_enabled(False)
    return {"PROJ_DATA": os.environ["PROJ_DATA"],
            "PROJ_NETWORK": os.environ["PROJ_NETWORK"],
            "pyproj_data_dir": pyproj.datadir.get_data_dir(),
            "network_enabled": pyproj.network.is_network_enabled()}

Removing PROJ_LIB is the detail that catches people. It is the older variable name, several tools still set it, and where both exist the behaviour depends on the PROJ version — so a carefully set PROJ_DATA can be silently overridden by an inherited PROJ_LIB from a parent process.

Grids fetched on demand compared with grids shipped with the environment Two columns. The on-demand column notes that the first transformation in a new environment reaches the network, that a machine without access falls back silently to a null transformation rather than failing, that the grid version depends on when it was fetched, and that a reproducible rerun a year later may use a different grid. The shipped column notes that the grid is present before any transformation runs, that an absent grid fails loudly at start, that the version is pinned with the environment, and that an offline or air-gapped machine behaves identically to a connected one. fetched on demand the first transformation reaches the network no access falls back silently to null the version depends on when it was fetched a rerun next year may use a different grid shipped with the environment present before any transformation runs an absent grid fails loudly at start the version is pinned with the environment offline behaves exactly like connected The silent null fallback is why this is a correctness question, not a convenience one.

Figure 3 — The cost of on-demand is a wrong answer, not a slow one.

Edge-case matrix

Situation Symptom Handling
Network fetching enabled Results depend on network state PROJ_NETWORK=OFF
PROJ_LIB inherited Wrong grid directory used Unset it explicitly
Grid not openly licensed Cannot be redistributed Obtain and install separately
Full grid set fetched Tens of gigabytes Fetch by bounding box
Grid revised upstream Coordinates change on re-fetch Pin the set; update deliberately
Cache in a user directory Differs per user and machine Use a project directory
Container without the grids Silent fallback Bake them in; verify on start-up
Multi-region project One bounding box is insufficient Fetch per region and merge

Verification snippet

import hashlib
from pathlib import Path

import pyproj


def verify_grid_environment(expected_digest: str, grid_dir: str) -> dict:
    """Confirm the running environment has exactly the expected grid set."""
    files = sorted(p for p in Path(grid_dir).rglob("*") if p.is_file())
    digest = hashlib.sha256()
    for path in files:
        digest.update(path.name.encode())
        digest.update(str(path.stat().st_size).encode())
    actual = digest.hexdigest()[:16]

    problems = []
    if actual != expected_digest:
        problems.append(f"grid inventory digest is {actual}, expected {expected_digest}")
    if pyproj.network.is_network_enabled():
        problems.append("PROJ network fetching is enabled — results may depend on it")
    if Path(pyproj.datadir.get_data_dir()) != Path(grid_dir):
        problems.append(f"pyproj is using {pyproj.datadir.get_data_dir()}, "
                        f"not {grid_dir}")

    return {"inventory_digest": actual, "files": len(files),
            "problems": problems, "ok": not problems}

Running that at worker start-up rather than at first use is the right placement. A worker with the wrong grid set should refuse to start, not process a survey and produce coordinates nobody can reproduce.

Grid set size by fetching strategy Three fetching strategies compared by the size of the resulting grid set. Fetching everything downloads about eighteen gigabytes. Fetching by a national bounding box downloads about four hundred megabytes. Fetching only the grids the project's transformations actually reference downloads about sixty megabytes. A note records that the smallest option is also the most reproducible, because the set is enumerated rather than bounded by geography. fetch everything ≈ 18 GB fetch by national bounding box ≈ 400 MB fetch only the referenced grids ≈ 60 MB — and enumerable The smallest set is also the most reproducible, because it is a list rather than a region.

Figure 2 — Three strategies, and why the narrowest is the best one for a pipeline.

Updating the pinned set

A pinned grid set is deliberately frozen, which means updating it is an event rather than a background process. Three steps make the update safe.

Fetch the new set into a separate directory and record its digest. Re-process a reference survey with both sets and compare the coordinates, which gives a concrete number for what the update changed. And record the change in the project history with that number, so a discontinuity in a monitoring series has a documented cause rather than appearing as unexplained movement.

Most grid updates move nothing measurable, and the ones that do are exactly the ones worth telling a client about before they notice. Keeping the previous set alongside the new one for an overlap period also makes it possible to reproduce an older deliverable exactly, which is occasionally required and impossible once the old grids are gone.

When to escalate

  • A required grid cannot be redistributed. Install it on the workers separately and verify its presence at start-up. The digest check handles the verification either way.
  • Two regions need incompatible grid versions. Process them separately and state the model used for each; merging products converted with different models introduces a step at the boundary.
  • The digest changes and nobody knows why. Something is fetching. Check that network fetching is off in every process, including any subprocess that inherits a different environment.

Geoid Models and Vertical Datum Automation