AyushDoesDev. / Journal
All notes
Technology shiftsยทยท9 min read

Python 3.15 abi3t: what native extensions need to change

Python 3.15 adds a Stable ABI for free-threaded CPython. It can reduce per-version wheel builds, but native-code compatibility, thread safety and release testing still need deliberate work.

Share

The wheel matrix is the problem this change targets

A Python package that contains native code has more to ship than a source archive. It needs binary wheels that match the Python implementation, version, ABI and platform. The Stable ABI, usually called abi3, helps extension authors avoid building a separate wheel for every CPython minor release. It does that by limiting the C API an extension can use to a subset whose compatibility is promised across later versions.

Free-threaded CPython created a separate compatibility problem. A free-threaded interpreter has a different object layout and cannot safely load every extension built for a conventional GIL-enabled interpreter. Before Python 3.15, maintainers who wanted to support free-threaded builds generally had to publish version-specific wheels such as cp314t for Python 3.14, while also maintaining their normal abi3 or version-specific wheels. A project that cannot yet adopt abi3t in Python 3.15 may still need a version-specific cp315t wheel.

That is manageable for one release. It becomes a maintenance burden as Python versions, operating systems and processor architectures multiply. PEP 803 addresses that packaging problem by introducing a Stable ABI for free-threaded builds: abi3t.

What abi3t promises โ€” and what it does not

Python 3.15 adds two Stable ABIs. The existing abi3 targets GIL-enabled CPython builds. The new abi3t targets free-threaded CPython builds and is available from Python 3.15 onward. Both are forward-versioned: an extension targeting Stable ABI 3.15 is intended to work on later compatible CPython minor versions without rebuilding for each one.

The two ABIs are related but deliberately distinct. The free-threaded ABI exposes a stricter subset of the C API. PEP 803 recommends that an extension which supports both interpreter types explicitly declare both compatibilities, using the combined wheel tag abi3.abi3t. Do not confuse wheel tags with shared-library filename suffixes: the C API documentation describes how interpreters search for abi3 and abi3t filenames on some platforms, while the wheel tag tells installers which interpreter builds a distribution supports. For a wheel intended for both GIL-enabled and free-threaded CPython, publish the combined abi3.abi3t tag only after testing both build types.

The important operational point is that an old abi3 wheel does not become free-threaded-compatible just because the package publishes a new release. The extension must be built against the right API contract, its wheel metadata must advertise the compatibility it actually has, and the installer must understand the tag. There is also a documentation inconsistency to be aware of: the Python 3.15 C API and ABI reference lists abi3t as introduced in 3.15, and PEP 803 is final, while the free-threading extension HOWTO still says the free-threaded build does not support the Limited C API or Stable ABI. For abi3t availability, follow the specific 3.15 ABI reference and PEP 803; use the HOWTO for its still-relevant module-support and thread-safety guidance. If a build tool repeats the older statement, check which headers and wheel tags it actually supports. The 3.15 ABI reference also adds a runtime compatibility check: most modules can declare the Py_mod_abi slot with PyABIInfo_VAR, or call PyABIInfo_Check directly. That check catches common mismatches only after a module has loaded, so it is a useful guardrail rather than a replacement for correct wheel tags and a tested interpreter matrix.

  • abi3: Stable ABI for GIL-enabled CPython.
  • abi3t: Stable ABI for free-threaded CPython, introduced in Python 3.15.
  • abi3.abi3t: an explicit combined wheel tag for an extension built and tested for both ABI families.

Why native extension source still needs work

The main source-level change is that abi3t makes PyObject, PyVarObject, PyModuleDef_Base and PyModuleDef opaque. An extension can no longer depend on their internal fields, size or memory layout. Code that embeds a PyObject structure in a custom instance, reads ob_type directly or assumes a particular module-definition layout has to move to supported APIs.

This is intentional. Free-threaded and GIL-enabled builds cannot share a stable binary contract if extensions are allowed to depend on object layouts that differ between them. Opaque structures give CPython room to change internal representation while keeping the public ABI stable.

Module initialization is another migration point. PEP 793 introduces the PyModExport entry point so an extension can describe a module with slots rather than returning a statically allocated PyModuleDef object. PEP 820 updates the slot representation to improve type safety and future compatibility. These changes are not a cosmetic rename: older module-definition patterns may need a real source migration before they can target abi3t.

The existing PyInit_* entry point is soft-deprecated, not immediately removed. That distinction matters for a project supporting several Python generations. A maintainer can keep a compatibility path for older interpreters while adding the newer export mechanism for Python 3.15 and later. The migration should be driven by the project's supported-version policy, not by deleting the old path simply because the new one exists.

ABI compatibility and thread safety are separate gates

A stable ABI answers a binary compatibility question: can this extension be loaded by the interpreter versions and build types named by its compatibility tag? It does not prove that the extension is safe when multiple Python threads execute it at the same time.

The GIL used to serialize many operations in ordinary CPython builds. Removing that global serialization can expose races in extension-owned global state, caches, reference handling and code that accesses Python objects while another thread may modify them. A package can compile for abi3t and still contain a concurrency bug.

The free-threading extension guidance calls out concrete hazards. Direct access to mutable object-structure fields is not generally thread-safe. Some convenience macros do not perform locking. Borrowed references can become unsafe when another thread mutates the containing object. Built-in containers use internal locks for individual operations in the free-threaded build, but that is not a promise that a multi-step sequence is atomic.

Treat shared mutable state as your responsibility. Use documented synchronization primitives for extension-owned caches and global state. Review C API calls that return borrowed references, and follow the current guidance for critical sections when iterating over a container that another thread may modify. Do not infer safety from a test that merely imports the module successfully.

Check the interpreter after importing the real dependency stack

A free-threaded build can run with the GIL enabled. CPython can also enable the GIL when an extension that has not declared free-threading support is imported. A multi-phase extension uses the Py_mod_gil slot to declare that it can run without the GIL; an extension that does not make the declaration can trigger a warning and cause the GIL to be enabled.

Check both the build configuration and the runtime state after importing the application's native dependencies. The configuration value says whether the interpreter was built to support free-threading; the runtime check tells you whether the GIL is currently enabled.

import sys
import sysconfig

free_threading_build = sysconfig.get_config_var("Py_GIL_DISABLED") == 1

if not free_threading_build:
    raise RuntimeError("This interpreter is not a free-threaded build")

# Import the same native dependencies that the application uses.
# import your_database_driver
# import your_numeric_or_crypto_extension

if sys._is_gil_enabled():
    raise RuntimeError("An imported extension or runtime setting enabled the GIL")

print("Free-threaded runtime remains active")
  • Run this check in the actual container or deployment image, not only on a developer laptop.
  • Import the full native dependency set before checking the GIL state.
  • Fail the test job if the GIL is unexpectedly enabled; do not count that run as a free-threaded test.

A rollout sequence that keeps the risk visible

Start with an inventory. List every dependency that ships a C, C++ or Rust extension, including transitive packages. Pure-Python packages can still have concurrency assumptions, but native extensions deserve an explicit compatibility check because they cross the interpreter's ABI boundary.

For each native dependency, record the wheel tags it publishes, the earliest Python version supported by those wheels, whether the maintainer documents free-threaded support, and whether the extension is known to keep the GIL enabled. Do not infer support from a package installing successfully: a version-specific wheel may be selected, or the GIL may have been re-enabled during import.

Next, build a test environment with the free-threaded interpreter and install the exact locked dependency set. Run import checks, unit tests, race-oriented stress tests and integration tests that exercise the extension's shared state. Keep the normal GIL-enabled environment in the matrix so the migration does not regress existing users.

Only then evaluate whether abi3t reduces the project's wheel workload. Some extensions may be ready to target the new Stable ABI; others may need source changes, a version-specific cp315t wheel or more time from their upstream maintainers. Python 3.14 and earlier cannot target abi3t, so supporting those versions still requires the existing compatibility strategy.

  • Inventory native dependencies and their transitive wheels.
  • Verify the installed wheel tags and the interpreter's GIL state.
  • Run tests under both GIL-enabled and free-threaded builds.
  • Measure the application's own workload before making performance claims.
  • Keep a fallback to the conventional build until the dependency stack is proven compatible.

What backend teams should take from Python 3.15

For application teams, abi3t is mostly an ecosystem signal. It gives native-extension maintainers a route to reduce repeated free-threaded builds, but the benefit arrives package by package. Your application is ready only when the complete dependency graph, not just the top-level framework, behaves correctly under the target interpreter.

For library maintainers, the work is more direct: review object-layout assumptions, module initialization, shared state and borrowed-reference usage; then publish wheels whose tags match the code that was actually built and tested. A combined abi3.abi3t wheel is useful only when both compatibility claims are true.

Do not switch a production service because a new ABI exists. First establish that the current bottleneck is CPU-bound Python execution, then test a representative workload with the same native libraries, data sizes and concurrency limits. The free-threaded build also has workload-dependent single-thread overhead and typically uses more memory than the default build. Record latency, throughput, memory and error behaviour before deciding whether it helps this service; the ABI alone cannot.

The useful mental model

There are three separate questions: can the wheel load, does the process actually run without the GIL, and is the extension's shared state safe under concurrency? abi3t addresses the first question for free-threaded CPython 3.15 and later. Runtime checks help answer the second. Code review and concurrency testing are still needed for the third.

That separation is the practical value of Python 3.15's change. It gives the ecosystem a more sustainable way to distribute native extensions for free-threaded builds without pretending that packaging compatibility is the same thing as thread safety.