manylinux2014 vs musllinux for Spatial Libs: Which ABI Baseline to Pick

When you package GDAL, PROJ, pyproj, rasterio, and shapely into wheels, the choice between a manylinux2014 (glibc) baseline and a musllinux (musl libc) baseline decides whether your spatial extensions import cleanly or fail at runtime with undefined-symbol and topology errors. This page is the decision guide for that one call, and it sits under the manylinux and manyarm Docker base images cluster within Modern Python Build Tooling & Wheel Configuration.

Context & Root Cause

Spatial wheels do not just ship Python — they bundle a deep stack of C/C++17 shared objects (GDAL, PROJ, GEOS, SQLite) whose behaviour is tied to the C library they were compiled against. manylinux2014 targets glibc 2.17+ (CentOS 7 baseline), the same floor the manylinux_2_28 base images raise to glibc 2.28. musllinux targets musl libc 1.1+ (the Alpine baseline). Musl is smaller and starts faster, but it intentionally omits glibc extensions that GDAL and PROJ implicitly rely on:

  • Missing __cxa_thread_atexit_impl / __cxa_finalize thread-local destructor semantics that PROJ uses for per-thread context teardown.
  • Incomplete iconv fallback chains used by PROJ coordinate-transformation pipelines.
  • libm precision differences that shift GEOS intersection tolerances and surface as silent topology failures.
  • Stricter symbol resolution (RTLD_LOCAL defaults) that breaks GDAL’s dynamic driver discovery.

Because these are ABI-level gaps, not API gaps, a pip install can succeed and the package still crash on first transform. The mismatch is the root cause behind the error signatures below.

glibc (manylinux2014) versus musl (musllinux) ABI surface for spatial libraries A comparison matrix of four ABI features that GDAL and PROJ depend on. The left column lists each feature: thread-local destructors (the __cxa_thread_atexit_impl symbol), iconv fallback chains, libm precision, and dlopen / RTLD defaults. The middle column shows that glibc, used by manylinux2014, provides all four: per-thread context teardown, complete charset chains, reference rounding, and RTLD_GLOBAL discovery. The right column shows that musl, used by musllinux, omits or weakens each one: the symbol is absent so the import fails, charset chains are partial so transforms fail, libm rounding drifts so GEOS reports topology errors, and RTLD_LOCAL defaults leave GDAL drivers unfound. Because the gaps are at the ABI level, an install can succeed and the package still crash on first use. Same spatial stack, two C runtimes — what each ABI actually provides ABI surface spatial libs need GDAL · PROJ · GEOS depend on these glibc — manylinux2014 C RUNTIME PROVIDES musl — musllinux_1_1 C RUNTIME OMITS Thread-local destructors __cxa_thread_atexit_impl Per-thread PROJ context teardown Symbol absent → ImportError iconv fallback chains PROJ transform pipelines Complete charset conversion Partial → transform fails libm precision GEOS intersection tolerance Reference rounding behaviour Drift → TopologyException dlopen / RTLD defaults GDAL driver discovery RTLD_GLOBAL discovery works RTLD_LOCAL → driver not found These are ABI gaps, not API gaps — a clean pip install can still crash on first transform.

Solution / Fix

Pick the baseline from the target runtime’s libc first, then confirm with the matrix. For anything that runs on a standard server, cloud VM, or Kubernetes node, the answer is manylinux2014 (or manylinux_2_28); reach for musllinux only when the deployment target is genuinely musl-based and you control the full runtime.

Decision flow for choosing a manylinux2014 or musllinux baseline A top-down decision tree. Start by asking what libc the target runtime uses. If it is glibc, which covers standard servers and cloud, pick a manylinux2014 or manylinux_2_28 baseline, which gives stable dlopen and full PROJ grids. If it is musl, used by Alpine and edge or IoT targets, ask whether you can static-link PROJ and GDAL and disable plugins. If no, fall back to the manylinux baseline. If yes, you may use the musllinux_1_1 baseline, but you must then validate libm precision and threading. Target runtime libc? glibc servers / cloud musl Alpine · edge / IoT GLIBC BASELINE manylinux2014 / manylinux_2_28 Static-link PROJ/GDAL & disable plugins? No — fall back Yes MUSL BASELINE musllinux_1_1 Stable dlopen · full PROJ grids Validate libm precision & threading before shipping
Workload profile Recommended baseline Why
Enterprise GIS, PostGIS connectors, heavy raster processing manylinux2014_x86_64 Stable dlopen, full PROJ grid support, glibc thread safety
Edge/IoT, Alpine containers, size-constrained runners musllinux_1_1_x86_64 Only with static PROJ/GDAL, disabled plugin loading, explicit PROJ_LIB
ARM64/Graviton spatial inference manylinux2014_aarch64 Cross-compiled or native; musl ARM spatial support is still fragmented
Pure-Python fallback / WASM N/A Bypass C extensions; use pyproj’s pure-Python transforms

Once you have chosen, enforce the baseline in CI so the image tag and the wheel tag can never drift apart. The build selector uses the generic platform name — the policy is selected by manylinux-x86_64-image, not inside the selector string. These tables are part of the wider pyproject.toml surface covered in mastering pyproject.toml for spatial wheels:

[tool.cibuildwheel]
build = "cp39-* cp310-* cp311-* cp312-* cp313-*"
# Select the manylinux2014 policy via the image, not the build selector.
manylinux-x86_64-image = "quay.io/pypa/manylinux2014_x86_64"
environment = { PROJ_LIB = "/project/share/proj", GEOS_CAPI_VERSION = "3.11.0" }
# CentOS 7-based manylinux2014 has no usable proj-devel/gdal-devel,
# so build PROJ/GDAL from source before the wheel build.
before-all = "yum install -y gcc-c++ sqlite-devel libtiff-devel libcurl-devel zlib-devel && bash scripts/build_geospatial_deps.sh"
test-command = "python -c \"import rasterio, shapely, pyproj; print('ABI OK')\""

To pin the musl path instead, swap a single line and add the static-link flags — but only after reading the trade-offs in the Pitfalls section:

[tool.cibuildwheel]
musllinux-x86_64-image = "quay.io/pypa/musllinux_1_1_x86_64"

Whichever baseline you select, keep PROJ grids inside the wheel rather than relying on the host. Bundling proj.db and the required grids in pyproj/data/ is the same discipline described under vendoring PROJ and GDAL vs system libraries, and how the loader finds them at runtime is covered in shared library path resolution.

Verification

After the build, confirm the wheel actually carries the baseline you intended and that its native dependencies resolve. The repair step that produces these tags is detailed in build artifact structuring and packaging — here you are auditing its output.

Check the platform tag on the produced wheel:

python -c "from packaging.utils import parse_wheel_filename as p; print(p('your_wheel.whl'))"
# Expected: ('your_wheel', <Version>, (), frozenset({cp310-cp310-manylinux2014_x86_64}))

Inspect the vendored shared objects and confirm no newer-glibc symbols leaked into a manylinux2014 wheel:

auditwheel show your_wheel.whl
python -m wheel unpack your_wheel.whl -d /tmp/wh
find /tmp/wh -name '*.so' -exec readelf -d {} + | grep NEEDED
# A manylinux2014 wheel must not reference GLIBC_2.32+ symbols.

Confirm PROJ resolves its data inside the wheel, not from a system path:

python -c "import pyproj; print(pyproj.datadir.get_data_dir())"
# Must return an absolute path inside site-packages, not /usr/share/proj

Then run a geometry/raster regression against a known-good dataset so a passing import does not mask a precision drift:

pytest tests/ --spatial-fixtures=tests/data/valid_geotiff.tif \
  -k "test_transform_and_rasterize" --tb=short

Pitfalls & Alternatives

Trusting that pip install success means the ABI is correct. It does not. Musl-built spatial wheels frequently install and import, then fail on the first transform or intersection. Always run auditwheel show, verify PROJ_LIB resolution, and execute geometry regression tests — never treat a clean install as validation.

Targeting musllinux without static-linking PROJ/GDAL or disabling plugins. This is the most common mistake. Dynamic driver discovery under musl’s strict symbol resolution leaves gdal.so unable to find its drivers, and auditwheel cannot patch musl-compiled C++ stdlib or PROJ grid dependencies. If you must ship musl, static-link the spatial stack and disable runtime plugin loading; otherwise fall back to manylinux2014.

Cross-compiling glibc and musl extensions in one shared runner. Mixing toolchains without an explicit CMAKE_TOOLCHAIN_FILE lets host libraries leak into the wheel and corrupt the platform tag. Keep manylinux and musllinux jobs isolated, and drive cross-builds through a dedicated cross-compiler toolchain setup. The locked dependency revisions for each runner should come from a reproducible pixi or conda environment.

Error signatures that point back to the wrong baseline

Error signature Root cause Fix
ImportError: .../pyproj/_proj.cpython-310-x86_64-linux-gnu.so: undefined symbol: __cxa_thread_atexit_impl musl lacks the glibc thread-local destructor ABI Rebuild on manylinux2014, or compile PROJ with -DCMAKE_THREAD_LOCAL_STORAGE=OFF
OSError: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.32' not found Wheel built on a newer-glibc host, deployed to an older runtime Pin CIBW_BUILD=cp310-manylinux2014_x86_64 and enforce the manylinux2014 image
auditwheel: ERROR: cannot repair wheel to "musllinux_1_1_x86_64" because it contains libraries with incompatible ABI tags auditwheel cannot patch musl C++ stdlib / PROJ grid deps Switch to manylinux2014, or static-link PROJ and re-tag
CRITICAL: proj_create_operations: Cannot find proj.db Alpine/musl images strip /usr/share/proj Set PROJ_LIB=/usr/local/share/proj and bundle grids in the wheel data directory
GEOSException: IllegalArgumentException: TopologyException: side location conflict musl libm rounding shifts GEOS tolerances Recompile GEOS with -ffloat-store, or move to the glibc baseline
ImportError: libgdal.so.33: cannot open shared object file: No such file or directory musl RTLD_LOCAL defaults break GDAL driver loading Set LD_PRELOAD=/usr/local/lib/libgdal.so, or rebuild with -DGDAL_ENABLE_DRIVER_PLUGIN=OFF

Further reading

  • PEP 599 (the manylinux2014 policy) and PEP 656 (the musllinux policy) define the platform tags referenced throughout this page.