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.
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 file —
versions.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:
GDAL_DATA=/opt/share/gdal PROJ_LIB=/opt/share/proj LD_LIBRARY_PATH=/opt/lib - IAM:
lambda:GetLayerVersionandlambda:UpdateFunctionConfigurationfor the deploy role
Implementation
Author the versions file once. Every other artifact derives from it.
# 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.
# 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:
# 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:
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:
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:
[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 notrasterio.__gdal_version__. The first is the Python package version; the second is thelibgdalthe wrapper actually loaded. Only the latter detects layer drift. Assert on__gdal_version__andpyproj.proj_version_str, never on the package versions alone. -
proj.dbcan drift independently oflibproj. A layer can carry the correctlibproj.soyet an out-of-dateproj.dbcopied from a different build. When PROJ cannot find a matching grid it degrades quietly to a ballpark transform. Include theproj_datapin inversions.tomland consider hashingproj.dbin 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 installin 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.
Related
- CI/CD Pipeline Sync for Geo Dependencies — the reproducible-build practice this pinning strategy completes
- Caching GDAL Build Artifacts in GitHub Actions — the same versions file doubles as the build cache key
- Native Library Compilation for Serverless — where the pinned libgdal and libproj are actually compiled
- Multi-Stage Dockerfile for GDAL on Cloud Run — apply the same runtime assertion inside container images
- Reducing Python GDAL Cold Starts with Provisioned Concurrency — how an init-time assertion behaves under a warm pool