How to Bundle PROJ Grid Files with an Application
Copy the grid files into a directory PROJ searches, point PROJ_DATA at it, and then assert at startup that the grid-based route is available — because if it is not, PROJ falls back to a less accurate transformation and returns coordinates that are wrong by a metre or two with no warning at all. The assertion is the part that matters; the copying is trivial.
This page belongs to the Datum Transformations & Projection Accuracy guide.
Why This Matters
PROJ’s design is helpful in a way that becomes dangerous in a pipeline: when it cannot run the best available transformation, it runs the next best instead of stopping. On a build machine with the grid installed, your reprojection is accurate to a few centimetres. On a field device without it, the same code silently uses a seven-parameter approximation and lands a metre and a half away.
Nothing distinguishes the two outputs. Both containers claim the same target reference system, both open cleanly, and both look right on a map. The difference only appears when someone compares the two, or stands where the boundary is supposed to be.
Prerequisites
- PROJ 7+ (the
PROJ_DATAvariable; older versions usePROJ_LIB) pyproj3.4+ to query which routes are available- The grid files for the transformations your data actually uses
- A packaging step you control — a container image, an installer, an app bundle
Primary Method
# Assert that the grid-based route is available before doing any work
from pyproj.transformer import TransformerGroup
REQUIRED_ROUTES = [
("EPSG:27700", "EPSG:4326"),
("EPSG:4326", "EPSG:27700"),
]
def assert_grids_available(max_accuracy_m: float = 0.2) -> None:
"""
Fail at startup if PROJ cannot run a transformation at least as accurate
as max_accuracy_m for every route this application depends on.
"""
problems = []
for src, dst in REQUIRED_ROUTES:
tg = TransformerGroup(src, dst)
if not tg.transformers:
problems.append(f"{src} -> {dst}: no usable transformation at all")
continue
best = tg.transformers[0]
accuracy = best.accuracy if best.accuracy is not None else 999.0
if accuracy > max_accuracy_m:
missing = [
g.short_name
for op in tg.unavailable_operations
for g in op.grids
]
problems.append(
f"{src} -> {dst}: best available is {accuracy} m "
f"({best.description}); missing grids: {missing or 'none reported'}"
)
if problems:
raise RuntimeError("PROJ grid check failed:\n " + "\n ".join(problems))
Both directions are listed deliberately. Grid availability is not symmetric in every configuration, and a pipeline that transforms out accurately and back approximately produces a round trip that does not close — a failure that looks like data corruption rather than a packaging problem.
Step-by-Step Walkthrough
1. Find out which grids you need
Ask PROJ rather than guessing. unavailable_operations names the grids and, usefully, gives a download URL for each.
from pyproj.transformer import TransformerGroup
tg = TransformerGroup("EPSG:27700", "EPSG:4326")
for op in tg.unavailable_operations:
print(op.name)
for grid in op.grids:
print(f" file: {grid.short_name}")
print(f" url: {grid.url}")
print(f" open licence: {grid.direct_download}")
Run this on a machine that does not have the grids installed — on one that does, the list is empty and tells you nothing.
2. Fetch and vendor the files
Grids are ordinary data files, and most are small enough to commit alongside an application. Fetch them once, at build time, and store them in your own artefact rather than downloading at run time — a field device may have no network at exactly the moment it needs one.
# Vendor the grids into the build context
mkdir -p vendor/proj
curl -fsSL -o vendor/proj/uk_os_OSTN15_NTv2_OSGBtoETRS.tif \
https://cdn.proj.org/uk_os_OSTN15_NTv2_OSGBtoETRS.tif
# Record what you vendored, so the next build can verify it
sha256sum vendor/proj/*.tif > vendor/proj/SHA256SUMS
The checksum file is worth the two seconds. A grid that arrives truncated is a file PROJ will reject at load, and the failure message points at the transformation rather than at the download.
3. Put them where PROJ looks
PROJ searches its data directory, and PROJ_DATA overrides where that is. Setting it to a directory containing only your grids removes the rest of the PROJ data — so include the vendored grids alongside the library’s own directory rather than instead of it.
# Prepend the bundled directory to PROJ's search path
import os
from pathlib import Path
import pyproj
BUNDLED = Path(__file__).parent / "vendor" / "proj"
pyproj.datadir.append_data_dir(str(BUNDLED))
print("PROJ data dirs:", pyproj.datadir.get_data_dir())
append_data_dir is the safe form because it adds to the search path rather than replacing it. Setting the PROJ_DATA environment variable to the bundled directory alone is the classic mistake: the grids resolve and proj.db no longer does, so every reference-system lookup fails.
4. Disable network fetching in production
PROJ can download grids on demand, which is convenient in development and unhelpful in a pipeline: it makes a build’s accuracy depend on network conditions at run time. Turn it off explicitly, so a missing grid is an error rather than a variable-latency download.
import pyproj
pyproj.network.set_network_enabled(active=False)
assert not pyproj.network.is_network_enabled()
With the network disabled and the grids bundled, the transformation route is fully determined by the artefact — which is the property that makes the output reproducible.
5. Run the assertion at startup
Call assert_grids_available() before the pipeline does any spatial work, and let it raise. A pipeline that fails in the first second with “missing grid: uk_os_OSTN15…” is a five-minute packaging fix; the same pipeline succeeding and producing metre-shifted coordinates is a week of investigation.
Verification
# In CI, and again as a startup smoke test in the deployed image
from pyproj.transformer import TransformerGroup
tg = TransformerGroup("EPSG:27700", "EPSG:4326")
best = tg.transformers[0]
assert "hgridshift" in best.definition or "gridshift" in best.definition.lower(), (
f"expected a grid-based route, got: {best.description}"
)
assert (best.accuracy or 999) <= 0.2, f"route accuracy is {best.accuracy} m"
assert not tg.unavailable_operations, (
f"{len(tg.unavailable_operations)} route(s) still unavailable"
)
print(f"grid route available: {best.description} ({best.accuracy} m)")
Pair that with the control-point residual check from Verifying a Reprojection with Known Control Points. The route assertion proves the right transformation is available; the residual check proves it is the one that actually ran.
Alternative Approaches or Edge Cases
Using the PROJ CDN with a local cache. PROJ can fetch grids on demand and cache them, which suits a long-running server on a reliable network. It does not suit a field device, a build that must be reproducible, or anything where the first run and the hundredth must behave identically. Where you use it, pre-warm the cache during the build so the artefact still ships with the data.
Old PROJ versions. PROJ 6 and earlier used NTv2 .gsb files and the PROJ_LIB variable; PROJ 7+ prefers GeoTIFF grids and PROJ_DATA. A bundle that supports both needs both formats and both variables, which is a reason to pin the PROJ version rather than to support a range.
Licensing. Not every grid is freely redistributable. grid.direct_download reports whether PROJ considers a grid openly available; where it is not, the grid must be obtained from the authority and the licence checked before it goes into an artefact you distribute.
Troubleshooting
CRSError: Invalid projection after setting PROJ_DATA
Cause: The variable was set to the bundled directory alone, so proj.db is no longer on the search path. Fix: Use append_data_dir, or include the library’s own data directory in the value.
Grids resolve locally and not in the container
Cause: The COPY in the image build put them somewhere PROJ does not search, or a multi-stage build dropped the layer. Fix: Print pyproj.datadir.get_data_dir() inside the container and compare it with where the files landed; add the startup assertion so this fails at deploy rather than at use.
The route is grid-based but the accuracy is still poor
Cause: A grid covering a different area — grids are regional, and PROJ will use one whose area of use does not contain your data if nothing better exists. Fix: Check the grid’s area of use against your extent, and confirm the residual against control points inside your working area rather than at the edge of the grid.
Frequently Asked Questions
How large are these files?
Most national grids are a few megabytes as GeoTIFF, and a bundle typically needs one or two. That is small against an application, and negligible against a tile pyramid — the size is almost never the reason not to bundle. The reasons that do come up are licensing and the assumption that the operating system will provide them, and only the first is a real constraint.
Should the grid ship inside the GeoPackage?
No — there is no place for it in the format, and a container is data rather than a transformation environment. What the container should carry is a record of which operation produced its coordinates, so a recipient can obtain the same grid and reproduce the result. The provenance table pattern in the parent guide is where that belongs.
Does this matter if everything is in EPSG:4326?
Only if the data reached 4326 from something else, which is nearly always. The transformation that produced the stored coordinates is the one that needed the grid, and if it ran without one, the container holds approximate coordinates regardless of what happens afterwards. Bundling matters at the point of conversion, not at the point of use.
Related
- Datum Transformations & Projection Accuracy — parent guide: pinning the route and recording it
- Datum Shifts and Why Coordinates Move by Metres — what the grid is modelling
- Verifying a Reprojection with Known Control Points — proving the accurate route actually ran
- Pinning GDAL, PROJ and GEOS Versions in CI — pinning the stack the grids sit inside