Fixing macOS deployment-target errors in spatial wheels
This page answers one question: your macOS GDAL wheel builds and then pip reports it is not a supported wheel on a user’s Mac, or it installs and fails with a symbol-not-found error naming a system framework — so how do you set MACOSX_DEPLOYMENT_TARGET consistently across the extension, the vendored libraries and the wheel tag? It sits inside the Platform-Specific ABI Quirks section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the three places the value has to agree and the commands that prove it does.
Context & Root Cause
macOS binaries record the oldest OS release they are prepared to run on, and the loader enforces it. That value comes from MACOSX_DEPLOYMENT_TARGET at compile time and is baked into each object independently — the extension, and every .dylib the repair step bundles beside it. The wheel’s platform tag is a separate declaration, written by the build backend, and pip uses only the tag when deciding whether a wheel is installable.
The mismatch is therefore easy to produce and invisible until it reaches a user. A common shape: the extension is compiled with a target of 11.0, but the vendored GDAL was built on a newer runner without the variable set, so it inherited that runner’s default of, say, 14.0. The wheel is tagged from the extension’s value, installs happily on macOS 12, and fails at import with a message about a symbol not found in a system library — because the bundled GDAL was built against an SDK whose symbols macOS 12 does not have. Nothing in the build noticed, because each step was internally consistent.
Solution / Fix
This targets macOS 12+ build runners, delocate 0.11+, cibuildwheel 3.0+, and GDAL 3.8.x / PROJ 9.3.x built from source.
1. Set the target once, in the environment, before anything compiles
export MACOSX_DEPLOYMENT_TARGET=11.0
Every subsequent compile — yours and every vendored library’s — inherits it. This is the single most effective change, because the failure mode above comes entirely from libraries built without it.
2. Pass it explicitly to CMake as well
Environment inheritance is not guaranteed through every build system, so state it where CMake will use it:
set(CMAKE_OSX_DEPLOYMENT_TARGET "11.0" CACHE STRING "Minimum macOS version")
cmake -S proj-9.3.1 -B build -DCMAKE_OSX_DEPLOYMENT_TARGET=11.0 ...
3. Declare it in the wheel build
[tool.cibuildwheel.macos]
environment = { MACOSX_DEPLOYMENT_TARGET = "11.0" }
4. Let delocate check rather than trust
delocate-wheel --require-target-macos-version 11.0 -w repaired/ -v dist/*.whl
That flag makes the repair step fail when a bundled library has a higher minimum than the target you declared — converting the latent runtime failure into a build error.
Verification
# 1. Every object in the wheel agrees on the minimum OS version
unzip -o dist/*.whl -d /tmp/w >/dev/null
for f in /tmp/w/**/*.so /tmp/w/**/.dylibs/*.dylib; do
printf '%-52s ' "$(basename "$f")"
otool -l "$f" | awk '/LC_BUILD_VERSION/{f=1} f&&/minos/{print $2; exit}'
done
# expected: 11.0 on every line
# 2. The tag on the filename matches
ls dist/*.whl
# expected: geo_core-2.4.1-cp39-abi3-macosx_11_0_arm64.whl
# 3. It installs and imports on the oldest macOS you claim
# (run on a machine or runner at that version)
pip install dist/*.whl && python -c "from osgeo import gdal; print(gdal.__version__)"
The first check is the one that catches the real problem. It walks every Mach-O object in the wheel rather than the extension alone, which is exactly where the inconsistency lives — a single vendored library with a higher minimum is enough to break the artifact for everyone below that version.
Choosing the Target
The value is a product decision rather than a technical one, and the constraints differ between the two architectures.
The third row is the situation most projects are actually in, and it has a distinctive symptom: the supported macOS range moves without any change to the repository, because the runner image was updated. Pinning the value explicitly removes the CI provider from the decision, which is the point.
For the Intel target, verify rather than assume. Parts of the C++17 standard library — std::filesystem in particular — were not available in the dylib shipped with older macOS releases, and GDAL and GEOS both use modern C++. If the build fails at 10.13, the honest options are to raise the target or to accept the dependency, not to leave it unset and let the runner decide.
Where the Value Gets Lost
Even with the variable exported, a few build paths drop it, and knowing which ones saves an afternoon.
The third row is worth a rule of its own: never bundle a prebuilt macOS library without checking its minimum. A single otool -l on each candidate takes seconds and is the difference between knowing your floor and discovering it from a user’s traceback.
Pitfalls & Alternatives
Setting the target lower than the SDK supports. Declaring 10.13 while building against a current SDK does not, by itself, prevent the compiler from emitting a call to a newer API. It suppresses the warning only if availability annotations are respected; the runtime failure is a missing symbol on the older system. Compile with the older SDK if you need certainty.
Assuming universal2 and the deployment target are the same question. They are independent: one is about which architectures are present, the other about which OS versions are supported. A universal2 wheel still has one deployment target, and it applies to both slices — which is why the practical floor for a fat wheel is 11.0, as building universal2 GDAL wheels for Apple Silicon explains.
Changing the target in a patch release. Raising it removes platforms from users with no signal other than a resolver that stops finding a wheel. Treat it like the abi3 floor: a public promise, changed deliberately and announced.
Trusting the tag over the objects. The tag is written from one value; the objects each carry their own. Always check the objects, because the tag cannot detect the inconsistency that causes the failure.
Frequently Asked Questions
Does the deployment target affect which SDK I can build with?
No — you can build with a current SDK and target an older OS, which is the normal arrangement. What the target changes is which APIs are considered available; calling a newer one produces a warning that becomes a runtime failure if ignored, so treat availability warnings as errors in a release build.
How do I find out what a prebuilt dylib targets?
otool -l libfoo.dylib | grep -A3 LC_BUILD_VERSION prints the minimum OS it declares. Run it on every library before bundling one you did not build; it takes seconds and it is the only way to know your real floor.
Should the two architectures have different targets?
They effectively do, because Apple Silicon did not exist before macOS 11, so an arm64 slice cannot target anything older. Separate thin wheels let each architecture declare its own honest floor, which is one more reason to prefer them over a fat wheel.
Is there an equivalent check to auditwheel for this?
delocate performs the closest equivalent when given the required-target flag: it compares every bundled library’s minimum against the value you declare and fails the repair on a mismatch. Without that flag it will happily bundle a library with a higher floor than the wheel claims.
Why does the wheel install on my Mac and not on the user’s?
Because installation matches the tag against the running OS, and your machine satisfies a tag their machine does not. The tag is usually right; what is wrong is that a bundled library requires more than the tag promises, which only shows up at import.
Should the target appear in the release notes?
For a package with macOS users, yes — the minimum macOS version is exactly as much a compatibility promise as the minimum Python version, and it is not visible anywhere a user would look. One line naming both is enough.
Does Rosetta affect the deployment target at all?
No. Rosetta translates an Intel binary on Apple Silicon, and the binary’s declared minimum still applies to the OS running it. A wheel whose Intel slice targets an older macOS runs under Rosetta on a modern machine perfectly well, because the modern machine exceeds the minimum rather than falling below it. The minimum belongs in the same sentence as the supported Python versions, because users read for both in the same place.
Related
- Platform-specific ABI quirks — the parent guide covering all three platforms’ repair models.
- Building universal2 GDAL wheels for Apple Silicon — the architecture question this one sits beside.
- Verifying wheel tags with auditwheel show — the Linux analogue of a tag that must match what the binary actually requires.