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.

The three places an abi3 build has to agree Three settings must name the same interpreter floor. The compile-time macro Py_LIMITED_API fixes which CPython symbols the code may use. The build backend's abi3 setting decides the tag written into the wheel filename. The project's requires-python field tells resolvers who may install it. If any one of the three disagrees with the others, the wheel either fails to import or excludes users unnecessarily. compile Py_LIMITED_API=0x03090000 decides what the code may call tag wheel.py-api = "cp39" decides the filename metadata requires-python = ">=3.9" decides who may install macro lower than tag → the wheel claims compatibility it does not have tag lower than metadata → resolvers exclude users the binary would serve all three equal → one wheel per platform, valid on every later CPython

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)

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.

What the limited API allows and what it withholds Allowed operations include creating and manipulating objects through functions, reference counting, argument parsing, buffer access, capsules and exception handling — everything a binding to a native library needs. Withheld are direct struct field access, fixed type object layouts, several fast-path macros, and any private underscore-prefixed function. A note records that a binding to GDAL or PROJ rarely needs anything in the withheld column. available object creation through functions Py_INCREF / Py_DECREF PyArg_ParseTuple and friends the buffer protocol capsules, exceptions, modules everything a native-library binding needs withheld reading struct fields directly static type objects with fixed layout fast-path macros over accessors anything prefixed with an underscore frame and code object internals rarely needed outside the interpreter itself the cost is a function call where a macro would have inlined a field read — invisible next to a PROJ transform

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.

Three checks that keep an abi3 build from silently regressing Three checks in the pipeline. A symbol scan asserts that no underscore-Py symbol is undefined in the built extension. An import test on the declared floor interpreter proves the wheel loads where it claims to. A tag assertion compares the wheel filename against the requires-python metadata. Each runs in seconds and catches a different way the build can drift. symbol scan nm -D --undefined-only _ext.so | grep '^_Py' catches a new call site that escaped the limited set — the failure no test would find floor import docker run python:3.9-slim … import geo_core catches a build that quietly targeted a newer interpreter than the tag claims tag agreement compare wheel tag with Requires-Python catches the two declarations drifting apart, which excludes users silently all three run in under a minute and belong in the validation job, not in a maintainer's memory

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.