Building abi3 wheels with Py_LIMITED_API
This page answers one question: what exactly do you set, in the source and in the build, so that a GDAL- or PROJ-linked extension produces a single cp39-abi3 wheel per platform that keeps working on every later CPython — and how do you prove the wheel really is limited-API clean rather than merely tagged as such? It sits inside the C-API vs CPython ABI compatibility section of the Geospatial C-Extension Fundamentals & ABI Architecture reference, and gives you the macro, the build wiring for three backends, and the two checks that catch a mislabelled artifact.
Context & Root Cause
CPython’s Stable ABI is a subset of the C-API whose layout and symbol set are guaranteed across minor releases. An extension compiled against that subset can be loaded by any interpreter at or above the declared floor, which is why one cp39-abi3 wheel replaces the four or five version-specific wheels a geospatial package would otherwise publish per platform — a saving that matters enormously when each of those builds compiles GDAL from source.
The reason it goes wrong is that Py_LIMITED_API restricts the headers, not your call sites. Anything reached through a macro that expands to a non-limited function, anything obtained from a third-party header that includes CPython’s internals, and anything using a struct whose layout is not part of the guarantee, compiles cleanly and produces a binary that references a symbol the Stable ABI does not promise. The wheel is tagged abi3, installs anywhere, and fails at import on the first interpreter whose internals moved. This is the enforcement gap the parent guide describes: the tag is a claim, and only the export table can confirm it.
Solution / Fix
This targets CPython 3.9–3.13, setuptools 69+, scikit-build-core 0.9+ and meson-python 0.16+.
1. Set the macro before any CPython header is included
/* Must precede Python.h — including it first fixes the full API in place. */
#define Py_LIMITED_API 0x03090000 /* floor: CPython 3.9 */
#include <Python.h>
The value encodes the floor as 0xMMmm0000. Choosing 3.9 rather than 3.8 is usually right today: it is the oldest interpreter most spatial packages still support, and several useful limited-API additions landed in it.
2. Wire the tag into the build
# scikit-build-core
[tool.scikit-build]
wheel.py-api = "cp39"
[tool.scikit-build.cmake.define]
Py_LIMITED_API = "0x03090000"
# setuptools
Extension("geo_core._ext", sources=["src/_ext.c"],
define_macros=[("Py_LIMITED_API", "0x03090000")],
py_limited_api=True)
# meson-python
py.extension_module('_ext', '_ext.c',
c_args: ['-DPy_LIMITED_API=0x03090000'],
limited_api: '3.9',
install: true)
3. Link against the stable library on Windows
Windows links a separate import library for the Stable ABI, and getting this wrong produces a wheel tagged abi3 that still binds version-specific symbols:
if(WIN32)
target_link_libraries(_ext PRIVATE python3) # not python39.lib
endif()
4. Declare the same floor in the metadata
[project]
requires-python = ">=3.9"
Verification
# 1. The filename carries the abi3 tag
ls dist/*.whl
# expected: geo_core-2.4.1-cp39-abi3-manylinux_2_28_x86_64.whl
# 2. No non-limited CPython symbol is referenced — the check the tag cannot make
unzip -o dist/*.whl -d /tmp/w >/dev/null
nm -D --undefined-only /tmp/w/**/_ext*.so | awk '{print $NF}' | grep '^_Py' | sort -u
# expected: empty
# 3. It imports on the floor and on the newest interpreter you support
for v in 3.9 3.13; do
docker run --rm -v "$PWD/dist:/d" python:$v-slim \
bash -c "pip install -q /d/*.whl && python -c 'import geo_core; print(\"$v ok\")'"
done
An abi3 tag, an empty _Py list and two clean imports together mean the wheel is what it says it is. The second check is the one worth wiring into CI permanently — it is the only one that distinguishes a correct build from a mislabelled one.
What the Limited API Costs You
The restriction is real but narrow, and knowing exactly what is off-limits prevents both over-caution and unpleasant surprises.
The one genuine design change is custom types: static type objects with a fixed layout are not part of the Stable ABI, so types must be created from a spec with PyType_FromSpec. For a geospatial binding that mostly wraps handles in capsules this is a small amount of boilerplate rather than a redesign, and it is a one-time cost.
Performance is essentially a non-issue in this domain. The limited API replaces a handful of macros with function calls; a coordinate transform spends microseconds inside PROJ per point and nanoseconds crossing that boundary. If profiling ever does show the boundary dominating, the fix is to move more work per call — pass arrays rather than points — which is the right answer regardless of ABI.
Keeping the Build Honest Over Time
An abi3 build is easy to establish and easy to lose, because nothing in a normal build fails when it regresses. Three habits keep it.
The symbol scan deserves to be a hard failure rather than a warning. A single new call to a non-limited function is enough to break the promise for every user on a future interpreter, and because the build succeeds and the tests pass, nothing else in the pipeline will notice. Ten lines in the validation job convert that into a red build the moment it is introduced.
Pitfalls & Alternatives
Defining the macro after including Python.h. The header configures itself on first inclusion, so a definition that arrives later has no effect at all — the build produces a full-API binary with an abi3 filename. Put the definition in the build system rather than in one source file, so it cannot be ordered wrongly by an unrelated include.
Assuming Cython handles it automatically. Cython can target the limited API, and older versions or unset directives produce code that uses internals freely. Set the option explicitly and run the symbol scan against the result; the generated C is not something anyone reviews line by line.
Linking python3X.lib on Windows. The tag says abi3 while the binary binds a version-specific import library, so it installs on 3.12 and fails to load. Link python3 instead, and include a Windows cell in the floor-import check.
Raising the floor in a patch release. Recompiling with a newer Py_LIMITED_API silently drops support for interpreters that previously worked. Treat the floor as a public contract, as the parent C-API vs CPython ABI compatibility guide argues, and raise it deliberately in a minor release.
Frequently Asked Questions
Can one project publish both abi3 and version-specific wheels?
It can, and the resolver prefers the more specific tag where one exists. It is a legitimate arrangement for a package needing a fast path on one interpreter and an unnecessary complication for most, since every artifact has to be validated separately.
Does the floor have to match requires-python exactly?
It has to be no higher. A floor of 3.9 with requires-python = ">=3.10" is merely conservative; the reverse — a 3.10 floor with >=3.9 — installs on an interpreter the binary cannot serve. Asserting equality in CI is the simplest way to keep them honest.
What happens on a CPython release that changes the Stable ABI?
Additions are the normal case and are backward compatible: a wheel built for 3.9 keeps working. Removals from the Stable ABI are rare and announced well in advance, and the symbol scan is what tells you whether you were using anything affected.
Is the free-threaded build covered by the same tag?
No — it is a distinct ABI with its own tag, so an ordinary abi3 wheel does not serve it. Supporting it means an additional matrix cell producing an additional artifact, which is worth planning for rather than discovering.
Does an abi3 wheel need rebuilding when a new CPython is released?
No, which is the whole return on the exercise: existing wheels serve the new interpreter on day one. What is worth doing is adding the new version to the import-test matrix, so you learn early if anything about the platform changed in a way that affects you.
How do custom types work under the limited API?
Through PyType_FromSpec, which builds a type from a description rather than from a statically laid-out struct. For a binding that mostly wraps native handles in capsules the change is boilerplate rather than redesign, and it is a one-time cost paid at migration.
Is there any reason not to build abi3?
Only a measured dependency on a non-limited API, which for a binding to a native library is rare. If profiling ever shows the boundary itself dominating, the productive answer is to move more work per call rather than to abandon the tag.
Related
- C-API vs CPython ABI compatibility — the parent contract, and how the abi3 decision collapses the build matrix.
- Diagnosing undefined-symbol errors in spatial extensions — what an escaped
_Pysymbol looks like when it reaches a user. - NumPy ABI and array interop in spatial extensions — the second, independent contract an abi3 wheel usually also has to satisfy.