Skip to content

Pinning GDAL and PROJ Versions Across Build and Runtime

Define GDAL, PROJ, GEOS, and the expected proj.db data version once in a single versions.toml, consume that file in both the layer build and your Terraform/CDK deployment, and assert the numbers at cold-start import with rasterio.gdal_version() and pyproj.proj_version_str. Drift between the layer’s compiled libgdal/libproj and the application wheel does not throw — it silently returns coordinates shifted by meters when PROJ falls back to a coarser datum transform, so a hard runtime assertion is the only reliable guard.


Single source-of-truth version pinning across build and runtime A versions.toml file feeds three consumers — the native library build, the application wheel constraints, and the IaC layer selection — which converge in the Lambda runtime where a startup assertion compares gdal_version and proj_version against the pinned values. versions.toml source of truth Native build compiles libgdal / libproj App wheel rasterio / pyproj pin IaC deploy layer ARN + env vars Runtime assertion gdal_version == pin proj_version == pin → else fail

Context

Every serverless GIS deployment has three independent places where a GDAL or PROJ version can be chosen, and the CI/CD pipeline sync for geo-dependencies discipline exists largely to keep them aligned. The first is the native library compilation step, which produces the libgdal.so and libproj.so inside the layer. The second is the application wheel — rasterio, pyproj, fiona — which is a thin Python wrapper that binds to whichever .so is on LD_LIBRARY_PATH at import. The third is the infrastructure-as-code that attaches a specific layer ARN version to the function.

When these three drift apart, the failure is uniquely dangerous because it is silent. A rasterio==1.4.3 wheel built against libgdal 3.9 will happily import against a layer that bundles libgdal 3.8; nothing raises. But pyproj transforms depend on proj.db, the SQLite database of datum grids and transformation pipelines. If the compiled libproj expects a newer proj.db schema than the layer ships — or vice versa — PROJ may quietly select a lower-accuracy fallback pipeline (a null datum shift instead of a NADCON or NTv2 grid), and your reprojected coordinates land meters to tens of meters off. The pixels still render; the numbers still look plausible; the map is simply wrong.

This is not a problem that stripping packages or caching build artifacts solves on its own. It requires a single source of truth that every consumer reads, plus a runtime check that refuses to serve traffic when the compiled reality disagrees with the pin.

Prerequisites

  • Runtime: Python 3.11 on AWS Lambda (Amazon Linux 2023), with the same approach applying to GCP Cloud Functions 2nd gen and Azure Functions
  • A single versions fileversions.toml — checked into the repo root of the layer project
  • Build script that sources the versions file (see caching GDAL build artifacts, where the same file is the cache key)
  • IaC: Terraform ≥ 1.6 or AWS CDK, able to read the versions file at plan/synth time
  • Environment variables set from the same source, never hand-typed:
    code
    GDAL_DATA=/opt/share/gdal
    PROJ_LIB=/opt/share/proj
    LD_LIBRARY_PATH=/opt/lib
    
  • IAM: lambda:GetLayerVersion and lambda:UpdateFunctionConfiguration for the deploy role

Implementation

Author the versions file once. Every other artifact derives from it.

toml
# versions.toml — the single source of truth for the native geo stack
[native]
gdal = "3.9.0"
proj = "9.4.0"
geos = "3.12.1"

[data]
# proj.db data package version must match the libproj minor series
proj_data = "1.19"

[layer]
# Bumped by CI when [native] changes; consumed by IaC to select the ARN
arn_version = 7

The build reads it so the compiled .so files carry exactly these versions. The runtime assertion below is the enforcement mechanism — it runs at import time, during the cold-start init phase, so a mismatched environment fails before it ever serves a request.

python
# geo_versions.py — imported at module top level, NOT inside the handler.
# Runs during Lambda init so drift fails the whole environment on cold start.
import tomllib
import sys
from pathlib import Path

import rasterio          # binds to libgdal.so on LD_LIBRARY_PATH
import pyproj            # binds to libproj.so + proj.db

# The versions.toml is packaged alongside the handler at deploy time.
_PINS = tomllib.loads(Path(__file__).with_name("versions.toml").read_text())

_EXPECTED_GDAL = _PINS["native"]["gdal"]      # "3.9.0"
_EXPECTED_PROJ = _PINS["native"]["proj"]      # "9.4.0"


def assert_geo_stack() -> dict[str, str]:
    """Fail fast if the compiled libgdal/libproj disagree with the pins.

    rasterio.__gdal_version__ reports the libgdal the wrapper actually
    loaded — not the version rasterio was built against. pyproj.proj_version_str
    reports the linked libproj. Comparing both catches layer/wheel drift that
    otherwise silently degrades CRS transforms.
    """
    actual_gdal = rasterio.__gdal_version__          # e.g. "3.9.0"
    actual_proj = pyproj.proj_version_str            # e.g. "9.4.0"

    mismatches = []
    if actual_gdal != _EXPECTED_GDAL:
        mismatches.append(f"libgdal {actual_gdal} != pinned {_EXPECTED_GDAL}")
    if actual_proj != _EXPECTED_PROJ:
        mismatches.append(f"libproj {actual_proj} != pinned {_EXPECTED_PROJ}")

    if mismatches:
        # Raise during init: CloudWatch records an Init error and the
        # environment is discarded before it handles any traffic.
        raise RuntimeError(
            "Geospatial stack version drift detected — refusing to serve. "
            + "; ".join(mismatches)
            + ". Rebuild the layer from versions.toml and redeploy."
        )

    return {"gdal": actual_gdal, "proj": actual_proj}


# Execute at import — during cold start, before the handler is ever called.
VERSIONS = assert_geo_stack()
print(f"geo stack OK: GDAL {VERSIONS['gdal']} / PROJ {VERSIONS['proj']}", file=sys.stderr)

The IaC layer reads the same file so the attached ARN version and the environment variables cannot diverge from the build:

python
# cdk_stack.py — layer version and env vars derived from versions.toml
import tomllib
from pathlib import Path
from aws_cdk import aws_lambda as lambda_, Duration

pins = tomllib.loads((Path(__file__).parent / "versions.toml").read_bytes().decode())
arn_ver = pins["layer"]["arn_version"]   # single source of truth, not a literal

gdal_layer = lambda_.LayerVersion.from_layer_version_arn(
    self, "GdalLayer",
    layer_version_arn=f"arn:aws:lambda:us-east-1:123456789012:layer:gdal-py311-x86_64:{arn_ver}",
)

fn = lambda_.Function(
    self, "GeoProcessor",
    runtime=lambda_.Runtime.PYTHON_3_11,
    layers=[gdal_layer],
    environment={
        "GDAL_DATA": "/opt/share/gdal",
        "PROJ_LIB": "/opt/share/proj",
        "LD_LIBRARY_PATH": "/opt/lib",
    },
    memory_size=1769,
    timeout=Duration.minutes(5),
)

Verification

Confirm the linked versions match the pins from inside the runtime-matched container before deploying:

bash
docker run --rm \
  -e LD_LIBRARY_PATH=/opt/lib \
  -e PROJ_LIB=/opt/share/proj \
  -v "$(pwd)/layer:/opt:ro" \
  public.ecr.aws/lambda/python:3.11 \
  python3 -c "
import rasterio, pyproj
print('gdal  linked :', rasterio.__gdal_version__)
print('proj  linked :', pyproj.proj_version_str)
print('proj.db data :', pyproj.datadir.get_data_dir())
rasterio.show_versions()
"

Expected output when build, wheel, and data all agree:

code
gdal  linked : 3.9.0
proj  linked : 9.4.0
proj.db data : /opt/share/proj
rasterio 1.4.3
    GDAL: 3.9.0
    PROJ: 9.4.0
    GEOS: 3.12.1

To prove the guard works, attach an older layer ARN and invoke once. The assertion should abort during init:

code
[ERROR] RuntimeError: Geospatial stack version drift detected — refusing to serve.
libproj 9.3.1 != pinned 9.4.0. Rebuild the layer from versions.toml and redeploy.

Gotchas and Edge Cases

  • rasterio.__version__ is not rasterio.__gdal_version__. The first is the Python package version; the second is the libgdal the wrapper actually loaded. Only the latter detects layer drift. Assert on __gdal_version__ and pyproj.proj_version_str, never on the package versions alone.

  • proj.db can drift independently of libproj. A layer can carry the correct libproj.so yet an out-of-date proj.db copied from a different build. When PROJ cannot find a matching grid it degrades quietly to a ballpark transform. Include the proj_data pin in versions.toml and consider hashing proj.db in the build so a stale copy fails the artifact cache key.

  • Container deployments still need the assertion. Even a self-contained image built via a multi-stage Dockerfile for GDAL on Cloud Run can pick up a mismatched wheel if pip install in the runtime stage pulls a newer binary wheel. Keep the runtime check regardless of packaging mechanism.

  • The init-time raise interacts with provisioned concurrency. With provisioned concurrency, a failed assertion aborts the pre-initialized environment and the provisioning event surfaces the error immediately — which is what you want: no half-working warm pool serving shifted coordinates.

Frequently Asked Questions

Why does a PROJ version mismatch cause wrong coordinates instead of an error?

PROJ ships its transformation grids and pipeline definitions in proj.db. If the compiled libproj expects one proj.db schema but the layer bundles another, PROJ may fall back to a less accurate transformation pipeline — for example a null datum shift instead of an NTv2 grid — rather than raising. The reprojection still returns numbers, just displaced by meters to tens of meters, which is exactly why the failure is silent and dangerous.

Is pinning the Python package version enough?

No. rasterio==1.4.3 pins the Python wrapper, not the libgdal it links against. The same wheel can bind to libgdal 3.8 or 3.9 depending on which layer is attached. Pin the native library versions in a source-of-truth file consumed by the build and IaC, and assert __gdal_version__ at runtime so the wrapper and the .so cannot diverge unnoticed.

Where should the runtime version assertion live?

Run it once at module import time, outside the handler, so it executes during the cold-start init phase and fails the whole environment before serving traffic. A failed assertion at init surfaces immediately as a CloudWatch Init error, whereas an in-handler check only fails per invocation and can let a partially drifted environment keep answering requests.


Back to CI/CD Pipeline Sync for Geo Dependencies