Install PROJ Datum Grids for PyQGIS

Transforming coordinates between datums — an old national datum and ETRS89, NAD27 and NAD83, a geoid model for heights — is only as accurate as the method PROJ can use. The most accurate methods interpolate in grid files published by mapping agencies; without the grid, PROJ falls back to a simple Helmert shift that can be off by one to several metres. QGIS installs ship only a few grids, so whether a transformation is accurate to centimetres or to metres often depends on files nobody remembered to install.

This recipe belongs to Coordinate Reference Systems. It finds which grid an operation needs, checks availability from Python, installs grids into PROJ's user directory, enables PROJ's content delivery network as an alternative, and makes scripts, servers and containers use the same grids so results agree everywhere.

Grid or fallbackA transformation from an old national datum to ETRS89 has a grid-based operation accurate to a few centimetres, which needs a grid file such as a NTv2 file, and a Helmert fallback accurate to about one to three metres. If the grid is missing, PROJ silently uses the fallback; QGIS warns in the interface but scripts get no message. Installing the grid makes the accurate operation available.Same call, very different accuracytransformold datum → ETRS89grid file presentNTv2 / GeoTIFF grid± a few cmgrid missingHelmert fallback± 1–3 m, silentlyscriptsno warningcheck first

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series, which use PROJ 9 with grids in GeoTIFF format.
  • Permission to write to your user profile, or to the PROJ data directory on servers.
  • The pair of CRSs you transform between.

Find which grid an operation needs

QGIS exposes PROJ's list of candidate operations for any CRS pair, with each operation's accuracy, availability and the grids it needs.

from qgis.core import QgsCoordinateReferenceSystem, QgsDatumTransform

src = QgsCoordinateReferenceSystem("EPSG:31467")   # DHDN / 3-degree Gauss-Kruger zone 3
dst = QgsCoordinateReferenceSystem("EPSG:25832")   # ETRS89 / UTM zone 32N

for op in QgsDatumTransform.operations(src, dst):
    acc = f"±{op.accuracy} m" if op.accuracy >= 0 else "unknown"
    state = "available" if op.isAvailable else "MISSING"
    print(f"{state:<9} {acc:<10} {op.name}")
    for g in op.grids:
        print(f"           grid {g.shortName} ({'installed' if g.isAvailable else 'not installed'})"
              f"  url={g.url}")

Breakdown: Operations are listed from most to least accurate. Each grid entry names the file, whether it is installed, and the URL it can be downloaded from — PROJ's CDN or the publishing agency. An operation marked missing with a grid "not installed" is exactly the case where installing a file upgrades the transformation from metres to centimetres. Running this for every CRS pair a project uses is a quick audit.

Check what a transform actually uses

Knowing that a grid exists is not enough; scripts should confirm which operation a transform will use, because the fallback is silent.

Confirm before you transformA script builds a QgsCoordinateTransform with the project's transform context, then asks which coordinate operation it will use and whether the transform is falling back to a less accurate one. If the operation is not the expected grid-based one, the script stops with a message naming the missing grid rather than producing metre-level errors.Fail loudly instead of drifting quietlybuild transformwith contextwhich operation?accuracy?fallback?expected → goelse stop

from qgis.core import QgsCoordinateTransform, QgsProject, QgsPointXY

xf = QgsCoordinateTransform(src, dst, QgsProject.instance())
p = xf.transform(QgsPointXY(3_480_000, 5_420_000))
print("operation:", xf.instantiatedCoordinateOperationDetails().name)
print("accuracy:", xf.instantiatedCoordinateOperationDetails().accuracy, "m")
if xf.fallbackOperationOccurred():
    raise RuntimeError("PROJ fell back to a less accurate operation - install the grid")

Breakdown: instantiatedCoordinateOperationDetails() reports the operation actually used after a transformation has run. fallbackOperationOccurred() is true when the preferred operation could not be used — typically because its grid was missing — and PROJ substituted another. Raising an error in that case turns a silent metre-level error into a clear instruction. Run a transform once before a batch and check, as in batch reprojecting vector layers.

Install grids into the user directory

PROJ searches several directories for grids, including a per-user directory that needs no administrator rights. Downloading the grid file there makes it available to QGIS and to every PROJ-based tool run by that user.

import os
from pathlib import Path
from qgis.core import QgsApplication, QgsNetworkContentFetcher
from qgis.PyQt.QtCore import QUrl, QEventLoop

def proj_user_dir():
    from qgis.core import QgsProjUtils
    for d in QgsProjUtils.searchPaths():
        if "proj" in d.lower() and os.access(d, os.W_OK):
            return d
    return os.path.join(QgsApplication.qgisSettingsDirPath(), "proj")

target_dir = Path(proj_user_dir())
target_dir.mkdir(parents=True, exist_ok=True)
print("installing into", target_dir)

def download(url, dest):
    fetcher = QgsNetworkContentFetcher()
    loop = QEventLoop()
    fetcher.finished.connect(loop.quit)
    fetcher.fetchContent(QUrl(url))
    loop.exec_()
    data = fetcher.reply().readAll()
    Path(dest).write_bytes(bytes(data))
    return len(data)

for op in QgsDatumTransform.operations(src, dst):
    for g in op.grids:
        if not g.isAvailable and g.url:
            size = download(g.url, target_dir / g.shortName)
            print(f"downloaded {g.shortName}: {size / 1e6:.1f} MB")

Breakdown: QgsProjUtils.searchPaths() lists the directories PROJ will search; the first writable one that looks like a PROJ directory is a good target, with QGIS's own profile proj folder as fallback — QGIS adds it to PROJ's search path. Downloading through QGIS's network stack respects proxy and authentication settings, which a plain urllib call might not. Restart QGIS after installing so PROJ re-reads its grid list; then rerun the operations check to confirm the grid is now available.

Or enable PROJ's network access

Instead of installing grids, PROJ can fetch the parts of grids it needs from its CDN on demand and cache them locally. QGIS exposes this as a setting.

from qgis.core import QgsSettings

s = QgsSettings()
print("network grids enabled:", s.value("proj/proj_network", False, type=bool))
s.setValue("proj/proj_network", True)
s.setValue("proj/proj_network_url", "https://cdn.proj.org")

Breakdown: With network access on, PROJ downloads only the grid tiles covering the coordinates being transformed and caches them in a local database, so the first transformation in a new area is slower and later ones are fast. It requires internet access at transformation time, which makes it unsuitable for offline field laptops and reproducible server jobs; installing the files is the better choice there. The setting takes effect after restarting QGIS. Check the exact settings keys in your QGIS's Options → CRS and Transforms dialog, where the same option appears.

Make servers and containers match

Desktop QGIS, a scheduled script on a server, and a Docker container may all have different grid sets — and then produce different coordinates from the same code.

Same grids everywhereA desktop with the grid installed, a server without it and a container with network access each pick a different operation for the same transformation, producing results that differ by metres. Pointing all three at the same grid directory through the PROJ_DATA environment variable, or baking the grids into the container image, makes them agree.One grid set, every environmentdesktopgrid installedcmserverno gridmetrescontainerCDN, if onlinevariesfix: PROJ_DATA → shared grid folder, or grids in the image

# server or container: point PROJ at a directory with the grids
export PROJ_DATA=/opt/proj-data:/usr/share/proj
python3 /opt/scripts/reproject_delivery.py

# Dockerfile: bake grids into the image so every run is identical
# COPY proj-grids/ /opt/proj-data/
# ENV PROJ_DATA=/opt/proj-data:/usr/share/proj

Breakdown: PROJ_DATA (called PROJ_LIB in older PROJ versions) lists directories PROJ searches, colon-separated on Linux and macOS. Adding a shared folder of grids ahead of the system directory gives every process the same set. In containers, copying grids into the image makes runs reproducible and offline-capable. Combine this with the fallback check above in every scheduled job, as described in running PyQGIS in a Docker container.

Grids for vertical transformations

Grids are not only for horizontal datums. Converting ellipsoidal heights from GNSS into heights above a national vertical datum needs a geoid model grid, and without it the vertical component is silently left unchanged or approximated.

src3d = QgsCoordinateReferenceSystem("EPSG:4937")      # ETRS89 3D (ellipsoidal heights)
dst3d = QgsCoordinateReferenceSystem("EPSG:25832+7837") # ETRS89 / UTM 32N + DHHN2016 height
for op in QgsDatumTransform.operations(src3d, dst3d)[:3]:
    print(op.isAvailable, op.name, [g.shortName for g in op.grids])

Breakdown: A compound CRS, written as horizontal plus vertical codes, asks PROJ for operations that also convert heights. The listed grids are geoid models; installing them is what makes the height conversion real. Differences between ellipsoidal and orthometric heights are tens of metres in most places, so a missing geoid grid is not a subtle error. Check vertical results against a benchmark with a published height.

QGIS version compatibility

QGIS 3.34 LTR, 3.40 LTR and QGIS 4 use PROJ 9, with grids distributed as GeoTIFF files (the older NTv2 .gsb and .gtx formats still work). QgsDatumTransform.operations, grid details, fallbackOperationOccurred and QgsProjUtils.searchPaths are available on all of these. The environment variable is PROJ_DATA on PROJ 9.1 and newer; PROJ_LIB still works as an alias.

Troubleshooting

  • The grid is installed but still "not installed". It is in a directory PROJ does not search, or QGIS has not been restarted.
  • Results differ between desktop and server. Different grid sets; share PROJ_DATA or bake grids into the image.
  • Network grids do nothing. The setting needs a restart, or a proxy blocks the CDN.
  • Heights are unchanged after transformation. No geoid grid; install it and use a compound CRS.

Conclusion

List operations for each CRS pair to see which grids give the best accuracy, check fallbackOperationOccurred in scripts, install missing grids into PROJ's user directory or enable network grids on connected desktops, point servers and containers at one shared grid folder, and remember that vertical transformations need geoid grids too.

Frequently Asked Questions

Do I need grids if I only use WGS 84 and UTM? Usually not; transformations within the same datum family are exact or nearly so. Grids matter for historical and national datums and for heights.

Where can I get all PROJ grids at once? The proj-data package from PROJ bundles all openly licensed grids; it is large, so install only what your region needs.

Does QGIS warn about missing grids? The desktop shows a message bar warning when a better operation is unavailable; scripts must check themselves.

How large are grid files? Most national horizontal grids are a few megabytes; geoid models range from a few to a few hundred megabytes. Installing the handful a project needs is rarely a storage concern.

Are grid-based transformations always better? For their area of validity, yes. Outside it, they do not apply, and PROJ chooses another operation.