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.

System linking versus vendoring for a geospatial wheel Two lanes compared. System linking: pip installs a tiny wheel, dlopen resolves libproj.so and libgdal.so against host apt or dnf packages, and the import breaks when the base image bumps the SONAME — wheel about 5 MB, high ABI drift, patched by the host package manager. Vendoring: PROJ and GDAL are built into the isolated prefix /opt/vendor, auditwheel repair copies the .so files into the wheel's .libs directory and rewrites RPATH to $ORIGIN/.libs, and the self-contained wheel imports with zero host dependencies — wheel 80 to 200 MB, ABI frozen at build time, patched only by rebuild and republish. vs System linking pip install — tiny wheel no native libs bundled dlopen resolves against host libproj.so / libgdal.so → apt · dnf Breaks on base-image upgrade host SONAME bump → ImportError Vendoring Build PROJ + GDAL from source → /opt/vendor (isolated prefix) auditwheel repair copy .so → .libs/ · RPATH=$ORIGIN/.libs Self-contained wheel imports zero host deps — frozen at build Trade-offs Trade-offs Wheel size ~5 MB ABI drift high — host-coupled Security patching host package manager Wheel size 80–200 MB / tag ABI drift none — frozen Security patching rebuild + republish

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, and zlib-devel for the build stage only — these are compiled in, not shipped.
  • Repair tooling: auditwheel>=6.0 on Linux, delocate>=0.11 on macOS. Older auditwheel releases do not understand the manylinux_2_28 policy and silently fall back to manylinux2014.
  • 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/vendor keeps them out of /usr so they cannot collide with system packages and gives auditwheel a clean directory tree to scan.
  • Shared, not static. -DBUILD_SHARED_LIBS=ON is preferred over static linking for geospatial wheels. Statically folding PROJ and GDAL into one .so regularly triggers duplicate-symbol errors from GEOS and libstdc++, and inflates the binary without any runtime gain because auditwheel bundles 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. If find_package(PROJ) still resolves incorrectly, the fix is documented in fixing CMake find_package for PROJ.
  • Repair command. auditwheel repair copies the vendored .so files into the wheel’s .libs directory and rewrites RPATH to $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.

  1. 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
    
  2. 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
    
  3. 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
    
  4. 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
    
  5. Repair the wheel. Pull the vendored .so files into the wheel and rewrite RPATH:

    auditwheel repair dist/*-linux_x86_64.whl \
      --plat manylinux_2_28_x86_64 -w wheelhouse/
    
  6. Ship the PROJ data alongside the libraries. PROJ needs proj.db at runtime; package the share/proj directory into the wheel (or set PROJ_DATA to 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/vendor once 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 aarch64 wheels on x86_64 runners, 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 enable CMAKE_CROSSCOMPILING unless you supply a real sysroot, or gdal-config probes will return host answers.
  • musl vs glibc. The vendored model still forks on the C library — a manylinux wheel will not load on Alpine. If you publish musllinux, 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 libtiff or sqlite (both transitively linked by GDAL) means a rebuild and republish, not an apt 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 disable PROJ_NETWORK if 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.