Vendoring PROJ and GDAL vs System Libraries
Choosing whether to vendor PROJ and GDAL into a Python geospatial wheel or to link against the host’s system-installed copies decides the reliability, portability, and maintenance cost of your distribution pipeline. This guide sits under the Geospatial C-Extension Fundamentals & ABI Architecture reference and isolates the linkage decision from the toolchain, ABI, and memory concerns documented elsewhere in that section. It targets PROJ 9.2–9.4, GDAL 3.6–3.8, cibuildwheel 2.16+, auditwheel 6.x, delocate 0.11+, and the manylinux_2_28 policy, and it walks an end-to-end vendoring build for pyproj/rasterio-style packages alongside the system-linking alternative so you can pick the right model per platform.
Prerequisites & Environment
Vendoring is only reproducible if every input that touches the native ABI is pinned. PROJ and GDAL drift across patch releases, and the auditwheel policy that decides which glibc symbols are acceptable changes between major versions, so fix all of them before writing a single CMake flag.
- Base image: build inside one of the manylinux_2_28 Docker base images that anchor glibc compliance (AlmaLinux 8 baseline, glibc 2.28), never your host distro. The host’s glibc and
libstdc++will otherwise leak into the artifact and break on older runners. - Native source versions: PROJ ≥ 9.2 and GDAL ≥ 3.6. GDAL 3.7+ requires PROJ 9.x at build time; mixing a GDAL 3.8 build against PROJ 8 fails at
find_package(PROJ). - System build deps:
cmake>=3.20,gcc-c++,sqlite-devel,libtiff-devel,libcurl-devel, andzlib-develfor the build stage only — these are compiled in, not shipped. - Repair tooling:
auditwheel>=6.0on Linux,delocate>=0.11on macOS. Olderauditwheelreleases do not understand themanylinux_2_28policy and silently fall back tomanylinux2014. - Wheel build frontend:
cibuildwheel>=2.16, which orchestrates the matrix and runs the repair step per platform.
Lock the toolchain itself, not just the Python dependencies. A reproducible build environment managed through a pixi environment lock file pins the compiler, PROJ, and GDAL to exact builds so the ABI you test locally is the ABI you ship:
# pixi.toml — pin the native stack so the vendored ABI is deterministic
[dependencies]
python = "3.9.*"
proj = "9.4.*"
gdal = "3.8.*"
libtiff = "*"
sqlite = "*"
c-compiler = "*"
cxx-compiler = "*"
cmake = ">=3.20"
The linkage model you choose here also interacts with the C-API vs CPython ABI compatibility contract: a statically linked, C+±heavy libgdal can drag in toolchain-versioned RTTI symbols that defeat an otherwise clean abi3 wheel, so vendoring and Stable-ABI decisions must be made together.
Linkage Models and the ABI Surface
System linking delegates binary resolution to the host package manager (apt, dnf, brew, or conda). At import time the dynamic loader resolves libproj.so.25 and libgdal.so.34 against whatever the host provides. This keeps the wheel tiny but couples it to the host ABI: when a downstream user upgrades a base container image and the SONAME bumps, the extension fails to load. The mechanics of how the loader searches for those libraries — RPATH, RUNPATH, LD_LIBRARY_PATH, and ldconfig — are covered in shared library path resolution, and they are exactly what system linking leaves exposed.
Vendoring instead bundles compiled PROJ and GDAL artifacts directly inside the wheel. After auditwheel repair, the native .so files live in the wheel’s .libs/ directory and the extension’s RPATH points at $ORIGIN/.libs, so imports resolve internally regardless of the host. The trade-off is size and patch responsibility, quantified in why vendoring PROJ causes wheel bloat: the bundled proj.db plus the native libraries push a wheel from ~5 MB to 80–200 MB, and each vendored copy is loaded into its own heap, a topic developed in memory management in geospatial extensions.
| Concern | System libraries | Vendored libraries |
|---|---|---|
| Wheel size | ~5 MB | 80–200 MB per platform tag |
| ABI drift on host upgrade | High — breaks on SONAME bump |
None — frozen at build time |
| Security patching | Reactive (host package manager) | Proactive (rebuild + republish) |
| Reproducibility | Depends on host state | Deterministic |
auditwheel compliance |
Often violates manylinux policy |
Compliant when repaired |
| Cold-start / image layer cost | Low | High |
For published wheels on PyPI the vendored model is effectively mandatory, because the manylinux policy forbids depending on system libraries that are not guaranteed across glibc versions. System linking remains viable for internal, image-pinned deployments where you control the base image and want minimal artifacts.
Core Configuration
The control surface for a vendored build is the [tool.cibuildwheel] table in pyproject.toml, whose canonical layout is covered in mastering pyproject.toml for spatial wheels. The before-all hook compiles PROJ and GDAL into an isolated prefix, environment exports the variables the extension build needs to find them, and repair-wheel-command runs auditwheel with the target policy. The following is a production-ready setup for vendoring PROJ 9.4 and GDAL 3.8 on manylinux_2_28_x86_64:
[build-system]
requires = ["setuptools>=68.0", "wheel", "Cython>=3.0"]
build-backend = "setuptools.build_meta"
[tool.cibuildwheel]
build = "cp39-* cp310-* cp311-* cp312-*"
skip = "*-musllinux_*"
archs = ["x86_64", "aarch64"]
test-command = "python -c \"import your_geospatial_pkg; print(your_geospatial_pkg.__version__)\""
[tool.cibuildwheel.linux]
before-all = """
set -e
yum install -y epel-release gcc-c++ cmake make sqlite-devel libtiff-devel libcurl-devel zlib-devel
export VENDOR_PREFIX=/opt/vendor
# Build PROJ
curl -sL https://download.osgeo.org/proj/proj-9.4.0.tar.gz | tar xz
cd proj-9.4.0 && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$VENDOR_PREFIX \
-DBUILD_SHARED_LIBS=ON \
-DBUILD_TESTING=OFF \
-DENABLE_TIFF=ON \
-DENABLE_CURL=ON ..
make -j$(nproc) && make install
cd ../..
# Build GDAL against the freshly vendored PROJ
curl -sL https://download.osgeo.org/gdal/3.8.4/gdal-3.8.4.tar.gz | tar xz
cd gdal-3.8.4 && mkdir build && cd build
cmake -DCMAKE_BUILD_TYPE=Release \
-DCMAKE_INSTALL_PREFIX=$VENDOR_PREFIX \
-DBUILD_SHARED_LIBS=ON \
-DGDAL_USE_EXTERNAL_LIBS=ON \
-DPROJ_INCLUDE_DIR=$VENDOR_PREFIX/include \
-DPROJ_LIBRARY=$VENDOR_PREFIX/lib/libproj.so ..
make -j$(nproc) && make install
cd ../..
# Refresh the linker cache for the build phase only
ldconfig $VENDOR_PREFIX/lib
"""
environment = { PKG_CONFIG_PATH="/opt/vendor/lib/pkgconfig", GDAL_CONFIG="/opt/vendor/bin/gdal-config", PROJ_LIB="/opt/vendor/share/proj", LD_LIBRARY_PATH="/opt/vendor/lib" }
repair-wheel-command = "auditwheel repair --plat manylinux_2_28_$AUDITWHEEL_ARCH -w {dest_dir} {wheel}"
The decisions encoded above:
- Isolated prefix. Installing both libraries to
/opt/vendorkeeps them out of/usrso they cannot collide with system packages and givesauditwheela clean directory tree to scan. - Shared, not static.
-DBUILD_SHARED_LIBS=ONis preferred over static linking for geospatial wheels. Statically folding PROJ and GDAL into one.soregularly triggers duplicate-symbol errors from GEOS andlibstdc++, and inflates the binary without any runtime gain becauseauditwheelbundles the shared objects anyway. - PROJ before GDAL. GDAL must be pointed at the vendored PROJ via
PROJ_INCLUDE_DIR/PROJ_LIBRARY, or CMake will discover the system PROJ and you will ship a mismatched pair. Iffind_package(PROJ)still resolves incorrectly, the fix is documented in fixing CMake find_package for PROJ. - Repair command.
auditwheel repaircopies the vendored.sofiles into the wheel’s.libsdirectory and rewritesRPATHto$ORIGIN/.libs, so the extension resolves its dependencies internally at runtime.
If your project drives the native build through CMake rather than a raw shell hook, route the same intent through the scikit-build-core backend that translates pyproject.toml into CMake invocations, and keep the PROJ/GDAL discovery flags in CMAKE_ARGS.
Step-by-Step Implementation
Each step is runnable. Run them inside the pinned manylinux container, not on your host, so the only glibc and libstdc++ in scope are the policy-compliant ones.
-
Stage the isolated prefix. Create the vendor root and the system build dependencies that get compiled in:
export VENDOR_PREFIX=/opt/vendor yum install -y gcc-c++ cmake make sqlite-devel libtiff-devel libcurl-devel zlib-devel -
Build PROJ into the prefix. PROJ first, because GDAL links against it:
curl -sL https://download.osgeo.org/proj/proj-9.4.0.tar.gz | tar xz cmake -S proj-9.4.0 -B proj-9.4.0/build \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$VENDOR_PREFIX \ -DBUILD_SHARED_LIBS=ON -DBUILD_TESTING=OFF cmake --build proj-9.4.0/build -j"$(nproc)" --target install -
Build GDAL against the vendored PROJ. Force discovery of the prefix copy:
curl -sL https://download.osgeo.org/gdal/3.8.4/gdal-3.8.4.tar.gz | tar xz cmake -S gdal-3.8.4 -B gdal-3.8.4/build \ -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=$VENDOR_PREFIX \ -DBUILD_SHARED_LIBS=ON \ -DPROJ_INCLUDE_DIR=$VENDOR_PREFIX/include \ -DPROJ_LIBRARY=$VENDOR_PREFIX/lib/libproj.so cmake --build gdal-3.8.4/build -j"$(nproc)" --target install -
Build the extension against the prefix. Export the discovery variables so the Python build finds the vendored stack rather than the system one:
export GDAL_CONFIG=$VENDOR_PREFIX/bin/gdal-config export PKG_CONFIG_PATH=$VENDOR_PREFIX/lib/pkgconfig export LD_LIBRARY_PATH=$VENDOR_PREFIX/lib python -m build --wheel -
Repair the wheel. Pull the vendored
.sofiles into the wheel and rewriteRPATH:auditwheel repair dist/*-linux_x86_64.whl \ --plat manylinux_2_28_x86_64 -w wheelhouse/ -
Ship the PROJ data alongside the libraries. PROJ needs
proj.dbat runtime; package theshare/projdirectory into the wheel (or setPROJ_DATAto the bundled path) so coordinate lookups do not fall back to a missing system database. The layout conventions are in build artifact structuring and packaging.
Verification
Verification is the only place where the isolation promise is actually checked. A wheel can build cleanly and still resolve a system library at import on the build host while failing on a clean runner — run all three checks.
Confirm the repaired wheel has no external dependencies and qualifies for the target tag:
auditwheel show wheelhouse/*.whl
# The wheel references the following external versioned symbols: GLIBC_2.17 ...
# This constrains the wheel to: manylinux_2_28_x86_64
# (No PROJ/GDAL listed as external — they are bundled in .libs/)
Inspect what the extension actually links against and confirm every PROJ/GDAL reference points inside the wheel:
unzip -o wheelhouse/*.whl -d /tmp/wheel >/dev/null
ldd /tmp/wheel/your_geospatial_pkg/*.so | grep -E 'proj|gdal'
# libgdal.so.34 => /tmp/wheel/your_geospatial_pkg/.libs/libgdal.so.34
# libproj.so.25 => /tmp/wheel/your_geospatial_pkg/.libs/libproj.so.25
Finally, install into a container with no system PROJ or GDAL and exercise a real transformation:
docker run --rm -v "$PWD/wheelhouse:/w" python:3.11-slim bash -c '
pip install /w/*.whl &&
python -c "from pyproj import CRS; print(CRS.from_epsg(4326).name)"'
# Expected: WGS 84 — resolved entirely from the bundled proj.db
The python:3.11-slim image ships no geospatial libraries, so a successful transformation proves the wheel is self-contained.
Optimization & Edge Cases
- Pre-compile the vendor prefix. Building PROJ and GDAL from source dominates wall-clock time and invalidates Docker layer caches on every network fetch. Build
/opt/vendoronce in a separate job, publish it as an artifact, and mount it during the wheel matrix — the build caching strategies page covers keying the cache on the pinned native versions so it invalidates only when the ABI changes. - Cross-architecture builds. For
aarch64wheels onx86_64runners, QEMU emulation works but is slow and PROJ/GDAL auto-detection routines occasionally misfire under emulation. Native ARM runners or an explicit cross-toolchain are faster; the toolchain wiring is detailed in cross-compiler toolchain setup. Do not enableCMAKE_CROSSCOMPILINGunless you supply a real sysroot, orgdal-configprobes will return host answers. - musl vs glibc. The vendored model still forks on the C library — a
manylinuxwheel will not load on Alpine. If you publishmusllinux, run a parallel build inside a musl image and repair it there; the loader and packaging differences are weighed in manylinux2014 vs musllinux for spatial libs. - Security posture shift. Vendored wheels bypass OS-level patching. A CVE in
libtifforsqlite(both transitively linked by GDAL) means a rebuild and republish, not anapt upgrade. Track the vendored versions in your SBOM and treat native-dependency CVEs as build-pipeline work; the hardening angle is covered in security boundaries and sandboxing. - Prune the wheel. Strip debug tables (
strip --strip-unneeded) and disablePROJ_NETWORKif you do not fetch remote grids; both materially cut the size tax described in the wheel-bloat analysis.
Troubleshooting
OSError: libproj.so.25: cannot open shared object file: No such file or directory
The extension was built against the vendored PROJ but the wheel was never repaired, so RPATH still points at the build prefix that does not exist on the user’s machine. Re-run auditwheel repair on the linux_x86_64 wheel before publishing, and confirm with ldd that libproj.so.25 resolves into .libs/. This is the canonical symptom of a system-linked wheel shipped without bundling.
auditwheel: error: cannot repair "..." to "manylinux_2_28_x86_64" ABI because of the presence of too-recent versioned symbols
A library in the prefix (often libstdc++ pulled in by GDAL’s C++ core, or a GDAL built against a newer toolchain) references a glibc symbol newer than the policy allows. Rebuild PROJ and GDAL inside the manylinux_2_28 image itself rather than on the host, or lower the target policy to match the symbols actually present.
ld: duplicate symbol '_GEOSGeomFromWKT_r'
Static linking pulled GEOS into both libproj and libgdal, so the final link sees two copies. Switch to -DBUILD_SHARED_LIBS=ON for both libraries (as in the configuration above) so GEOS is resolved dynamically once, instead of folding it into multiple archives.
pyproj.exceptions.CRSError: Invalid projection: ... proj_create: Cannot find proj.db
The native libraries were bundled but the share/proj data directory was not, or PROJ_DATA points at a path that does not exist inside the installed wheel. Package proj.db into the wheel and verify it at runtime with python -c "import pyproj; print(pyproj.datadir.get_data_dir())", which must resolve to a path that contains proj.db.
Related
- Geospatial C-Extension Fundamentals & ABI Architecture — the parent reference covering compile, link, repair, and import for native geospatial extensions.
- Why vendoring PROJ causes wheel bloat — the size breakdown and mitigation tactics for the artifact tax this decision creates.
- Shared library path resolution — how
RPATH,RUNPATH, and the loader findlibproj/libgdal, which is exactly what vendoring rewrites. - C-API vs CPython ABI compatibility — why static C++ runtime coupling from a vendored
libgdalcan defeat an otherwise cleanabi3build. - manylinux_2_28 Docker base images — the glibc-pinned containers where vendored PROJ and GDAL must be compiled to stay policy-compliant.