Pinning GDAL and PROJ versions across a wheel set
This page answers one question: your project publishes several wheels — core bindings, a data package, a format extension — and each vendors GDAL and PROJ, so how do you guarantee that every platform and every package in the set carries the same native versions rather than whatever each build happened to resolve? It sits inside the Vendoring PROJ and GDAL vs System Libraries section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the single source of truth, the propagation and the assertion.
Context & Root Cause
A spatial project rarely ships one wheel. There is usually a core bindings package, often a companion data package, and frequently one or more format extensions — and each of them vendors some part of the same native stack. When those builds resolve their dependencies independently, nothing keeps them in step: one package’s matrix runs on Tuesday and picks up PROJ 9.3.1, another runs on Thursday after a base-image refresh and picks up 9.3.2.
Two failures follow. The visible one is a symbol collision when both packages are installed together, as avoiding symbol collisions between spatial wheels describes. The quieter one is a numerical difference: two PROJ patch releases can select different transformation pipelines for the same coordinate pair, so the same environment produces different answers depending on which package’s copy is used. Neither failure is reported by any build step, because each build was internally correct.
Solution / Fix
This targets a multi-package project with vendored GDAL 3.8.x, PROJ 9.3.x and GEOS 3.12.x, built through the pipeline described in manylinux and manyarm Docker base images.
1. Put the versions in one file, with checksums
# ci/native-versions.lock — the only place these numbers appear
gdal=3.8.4 sha256=8a17f8b7b1e... url=https://download.osgeo.org/gdal/3.8.4/gdal-3.8.4.tar.gz
proj=9.3.1 sha256=b0f919cb9b0... url=https://download.osgeo.org/proj/proj-9.3.1.tar.gz
geos=3.12.1 sha256=d6ea231cf9c... url=https://download.osgeo.org/geos/geos-3.12.1.tar.bz2
sqlite=3.45.1 sha256=cd9c27841b7... url=https://sqlite.org/2024/sqlite-autoconf-3450100.tar.gz
2. Read it from the build script rather than restating the versions
# ci/build-native.sh
while read -r line; do
eval "$line" # sets gdal=, sha256=, url= per row
curl -fsSLO "$url"
echo "$sha256 $(basename "$url")" | sha256sum -c -
done < ci/native-versions.lock
3. Make the file part of every cache key
key: native-${{ matrix.os }}-${{ matrix.arch }}-${{ hashFiles('ci/native-versions.lock', 'ci/build-native.sh') }}
4. Record the resolved versions inside each wheel
# generated during the build, shipped in every package
VENDORED = {"gdal": "3.8.4", "proj": "9.3.1", "geos": "3.12.1"}
Verification
# 1. Every wheel in the set reports the same native versions
for whl in dist/*.whl; do
python - "$whl" <<'PY'
import sys, zipfile, re
z = zipfile.ZipFile(sys.argv[1])
name = next(n for n in z.namelist() if n.endswith("_vendored.py"))
print(sys.argv[1].split("/")[-1], dict(re.findall(r'"(\w+)": "([\d.]+)"', z.read(name).decode())))
PY
done | awk '{$1=""; print}' | sort -u | wc -l
# expected: 1 — one distinct version set across the whole release
# 2. The libraries actually inside the wheels match what they claim
unzip -o dist/geo_core*.whl -d /tmp/a >/dev/null
strings /tmp/a/*.libs/libproj*.so* | grep -oE 'PROJ [0-9]+\.[0-9]+\.[0-9]+' | head -1
# expected: PROJ 9.3.1
# 3. Installing the whole set together produces one coherent environment
pip install dist/*.whl
python -c "
import geo_core, geo_formats
assert geo_core.VENDORED == geo_formats.VENDORED, (geo_core.VENDORED, geo_formats.VENDORED)
print('set is coherent')"
The first check is the release gate. One distinct version set across the whole release is the property you actually want, and it is checkable in a few lines from the artifacts themselves rather than by reasoning about the pipeline.
What a Version Bump Actually Changes
Pinning is only half the discipline; the other half is knowing what moves when the pin does, so that a bump is a decision rather than a routine refresh.
The first row is the one that surprises people, because “patch release” implies nothing user-visible. For PROJ it can mean a different transformation pipeline is selected for a particular datum pair, which moves coordinates by centimetres — invisible in a web map, a defect in a survey workflow. That is why a fixed coordinate fixture belongs in the validation gate, as testing coordinate accuracy across PROJ versions sets out.
The middle row has a packaging consequence worth planning for: a soname change means every wheel in the set has to be rebuilt and republished together. A release where the core package moved to the new GDAL and the format extension did not is precisely the mixed environment the pinning is meant to prevent.
Coordinating a Bump Across Several Packages
When one file drives several repositories or several packages, the bump becomes a small release-management exercise rather than a commit.
The constraint mentioned in the last line is the escape hatch when a simultaneous release genuinely cannot happen: declaring that geo-formats requires geo-core >= 2.5 makes the resolver refuse the mixed combination rather than installing it. It is a blunt instrument and it does the job, and it should be removed once the set is level again.
Pitfalls & Alternatives
Restating versions in the Dockerfile and the CI file. Two copies drift within two releases, and the drift is invisible because both builds succeed. One file, read by everything.
Pinning versions without checksums. A version number identifies a release; a checksum identifies the bytes. Upstream tarballs have been re-rolled, and mirrors are not always faithful. The checksum costs one line and turns a silent substitution into a failed build.
Bumping the base image and the library versions together. Both change the compiled output, and doing them in one step makes any resulting difference unattributable. Separate commits, separate builds.
Assuming the pure-Python packages are unaffected. A data package carrying proj.db is versioned against the PROJ that reads it, as bundling proj.db and datum grids in a wheel explains — so it belongs in the coordinated release even though it compiles nothing.
Frequently Asked Questions
How often should the pinned versions move?
On a deliberate schedule rather than continuously — quarterly is a reasonable default for a package with a broad user base, with out-of-band moves for security advisories. What matters more than the cadence is that each move is a reviewed change with a rebuilt matrix behind it, rather than something that happens because a base image refreshed.
Should the pin be a patch version or a minor range?
An exact version, with a checksum. A range reintroduces exactly the drift the pin exists to remove: two builds a week apart resolve differently and produce wheels that differ in ways nobody chose. Ranges belong in the runtime dependency metadata, where a resolver needs flexibility; the native stack is compiled, not resolved.
What if one package in the set genuinely needs a different GDAL?
Then it is a different environment, and pretending otherwise produces the collision the pinning exists to prevent. Either bring the package to the shared version, or make the divergence explicit with a dependency constraint so the two cannot be installed together — silence is the only option that fails badly.
Does the pin need to cover the codecs as well as GDAL, PROJ and GEOS?
Yes, and it is easy to forget because they are transitive. libtiff, libwebp, libcurl and SQLite all end up inside the wheel, and all of them appear in advisories. Pinning only the three headline libraries leaves most of the bundled surface floating.
Where should the version file live in a multi-repository project?
In whichever repository owns the base image, published as a small artifact the others fetch by tag. Copying it into each repository reintroduces the drift; referencing it means a bump is one change with a visible set of consumers, and a build that cannot fetch it fails loudly rather than falling back to a stale copy.
Does this apply to a project with only one wheel?
The single-source rule still pays, because a project with one wheel still has several places the versions can appear: the Dockerfile, the CI configuration, the build script and the release notes. Pinning in one file and reading it everywhere removes the drift between those four, which is the same problem at smaller scale.
Can the pin be relaxed for development builds?
It can, and doing so quietly defeats the exercise: a contributor who builds against whatever is installed will produce different behaviour from CI and will not know why. Keeping the same pin locally, through the same script, is what makes a local reproduction of a CI failure meaningful. Providing a single command that populates a local environment from the same pinned file removes the temptation entirely, which is a better defence than a note in the contributing guide.
Related
- Vendoring PROJ and GDAL vs system libraries — the decision that makes these versions your responsibility.
- Avoiding symbol collisions between spatial wheels — what happens when the set drifts apart.
- Dependency resolution and lockfiles — the same discipline applied to the build environment rather than the vendored stack.