Pinning GDAL and PROJ Versions Reproducibly
Two builds of the same commit should produce the same stack. That takes a hashed lock file, a pinned base image, and a recorded version manifest:
pip-compile --generate-hashes --output-file requirements.lock requirements.in
FROM python:3.11.9-slim-bookworm
RUN pip install --require-hashes -r requirements.lock
RUN python -c "import rasterio, pyproj, json; json.dump( \
{'rasterio': rasterio.__version__, 'gdal': rasterio.__gdal_version__, \
'proj': pyproj.proj_version_str}, open('/app/versions.json','w'))"
Reproducibility is the reason the images in Containerizing Geospatial Python Environments exist at all.
Why This Arises in Remote Sensing Workflows
The geospatial stack has more moving parts below Python than most. A reprojection’s numeric result depends on the PROJ version and on which datum grids are installed. A COG read’s request pattern depends on the GDAL version’s defaults. A compression setting may be unavailable in one build and present in another. None of these appear in a pip freeze if you are looking only at Python packages.
The consequence is a specific, frustrating class of bug: the same code, the same inputs, and different numbers on two machines — or on the same machine before and after a rebuild. It presents as a data problem, gets investigated as a data problem, and turns out to be a library problem that nobody recorded.
Pinning does not prevent change; it makes change deliberate. The goal is that any difference in behaviour between two runs can be traced to a commit that someone reviewed, rather than to the day the image happened to be built.
Environment & Setup
| Tool | Version | Why |
|---|---|---|
pip-tools or uv |
current | Produces a fully-resolved, hashed lock file |
pip |
≥23.1 | --require-hashes enforcement |
rasterio |
≥1.3.0 | Exposes __gdal_version__ |
pyproj |
≥3.4 | Exposes proj_version_str and the data directory |
pip install pip-tools
Complete Working Example
The three pieces: a lock file, an expectation file, and a check that fails when they diverge.
"""stack_check.py — record and verify the resolved geospatial stack."""
import json
import os
import sys
import pyproj
import rasterio
EXPECTED_PATH = os.environ.get("STACK_EXPECTED", "stack_expected.json")
RECORDED_PATH = os.environ.get("STACK_RECORDED", "/app/versions.json")
def resolved() -> dict:
"""Everything that can change behaviour, as far down the stack as we can see."""
return {
"python": sys.version.split()[0],
"rasterio": rasterio.__version__,
"gdal": rasterio.__gdal_version__,
"proj": pyproj.proj_version_str,
"proj_data_dir": pyproj.datadir.get_data_dir(),
"numpy": __import__("numpy").__version__,
"gdal_drivers": len(rasterio.drivers.raster_driver_extensions()),
}
def record(path: str = RECORDED_PATH) -> dict:
info = resolved()
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "w") as fh:
json.dump(info, fh, indent=2, sort_keys=True)
return info
def verify(expected_path: str = EXPECTED_PATH) -> None:
"""Fail loudly when the resolved stack differs from the reviewed one."""
with open(expected_path) as fh:
expected = json.load(fh)
actual = resolved()
drift = {k: (expected[k], actual[k])
for k in expected
if k in actual and expected[k] != actual[k]}
if drift:
lines = [f" {k}: expected {exp!r}, got {act!r}" for k, (exp, act) in drift.items()]
raise SystemExit("geospatial stack drifted:\n" + "\n".join(lines))
print("stack matches expectations:", json.dumps(actual, indent=2, sort_keys=True))
if __name__ == "__main__":
if len(sys.argv) > 1 and sys.argv[1] == "record":
print(json.dumps(record(), indent=2, sort_keys=True))
else:
verify()
Wire it into the build so a drifted stack cannot ship:
COPY stack_expected.json /app/stack_expected.json
COPY stack_check.py /app/stack_check.py
RUN python /app/stack_check.py record && python /app/stack_check.py
The proj_data_dir field is worth including even though it is a path rather than a version: it reveals whether PROJ is reading the data bundled with the wheel or a system copy, which is the usual explanation when two images with identical version numbers still disagree.
Variant Patterns
1. Pinning when using a system GDAL
Building rasterio against a system GDAL moves the pin from the wheel to the base image tag, so the tag has to be exact.
# Not :latest, not :3.8 — the full version, so a rebuild in six months is identical
FROM ghcr.io/osgeo/gdal:ubuntu-small-3.8.4
RUN pip install --require-hashes --no-binary rasterio -r requirements.lock
Digest pinning goes further and removes even the possibility of a retagged image:
FROM ghcr.io/osgeo/gdal@sha256:9c1e0f... # immutable
Digests are unreadable and unambiguous, which is exactly the trade a reproducible build wants.
2. Shipping datum grids instead of downloading them
ENV PROJ_NETWORK=OFF
COPY proj_grids/ /usr/local/share/proj/
Which grids matter depends on the CRSs in use; the transformation-path discussion in Transforming Point Coordinates with pyproj explains how to find out which ones your operations select.
3. Recording the stack in every output
The image records its stack; outputs should too, so a file can be traced back to what produced it.
import json
import rasterio
stack = json.load(open("/app/versions.json"))
with rasterio.open(dst_path, "w", **profile) as dst:
dst.write(arr, 1)
dst.update_tags(**{f"stack_{k}": str(v) for k, v in stack.items()})
When two products disagree, comparing their tags takes seconds; reconstructing which image built each takes hours.
Detecting Drift Before It Ships
The check above is a build gate, but drift can also be monitored across the fleet.
Compare recorded stacks across images. If several pipelines share a base, their versions.json files should be identical, and a diff is the fastest way to find the one that was rebuilt against something newer.
Watch for silent platform changes. A wheel built for a new manylinux tag can bring a different bundled GDAL under the same rasterio version, which the version pin does not catch but the recorded gdal field does.
Schedule the update rather than absorbing it. A monthly bump of the lock file, run through the same smoke test and a numeric comparison on a fixed test scene, turns library updates into a reviewed change with evidence — the same discipline applied to data conventions in Auditing CRS and nodata Drift Across a Collection.
Common Errors
ERROR: In --require-hashes mode, all requirements must have their versions pinned
A transitive dependency is missing from the lock file. Regenerate it with --generate-hashes rather than hand-editing; hand-maintained lock files drift within a week.
The lock file resolves differently on another machine
The lock was generated on a different Python version or platform. Generate it inside the same base image the build uses, so the resolution sees the same environment.
GDAL version changed without any pin changing
The wheel was rebuilt for a new platform tag, or the base image’s floating tag moved. Pin the base image by digest and record the GDAL version so the change is visible.
Frequently Asked Questions
Q: Does pinning rasterio pin GDAL? When installing from binary wheels, effectively yes: each rasterio wheel bundles a specific GDAL and PROJ build, so a pinned rasterio version and platform give the same libraries. When building against a system GDAL, it pins nothing below the Python layer.
Q: Why do hashes matter if versions are pinned? A version pin identifies a release; a hash identifies the exact artefact. Hashes protect against a re-uploaded file, a mirror serving something different, and a wheel rebuilt for a new platform tag — all of which change behaviour without changing the version.
Q: How often should pins be updated? On a schedule you control rather than by accident — monthly is common. The point of pinning is not to freeze forever but to make every change deliberate, reviewed and attributable to a commit.
Related
- Containerizing Geospatial Python Environments — the parent topic and its image-level decisions.
- Building a Slim GDAL Docker Image — the build that consumes this lock file.
- Caching PROJ Data and GDAL Config in Containers — shipping the grids this page argues for.
- Transforming Point Coordinates with pyproj — why a PROJ version change moves coordinates.