PEP 803: What abi3t Means for Python C Extension Authors
Your cryptography package installs fine on Python 3.14 — until a user with a free-threaded build tries to import it. The shared library loads, the interpreter segfaults, and you get a bug report that reads: ImportError: /usr/lib/python3.14/site-packages/cryptography.hazmat/bindings/_openssl.abi3.so: undefined symbol: _PyRuntime.
You’ve just hit the core problem PEP 803 solves.
When the Steering Council accepted PEP 779 (free-threaded CPython), it made a commitment: Stable ABI support for free-threading must be ready for Python 3.15. PEP 803 — accepted March 30, 2026 — delivers exactly that. It defines abi3t, a new variant of Python’s Stable ABI specifically for free-threaded builds.
If you write C extensions, this changes your build pipeline. Here’s what you need to know, with working examples.
What abi3t Actually Is
Python’s Stable ABI (abi3, defined in PEP 384) lets you compile an extension once and load it on any Python 3.x where the ABI is compatible. You compile with Py_LIMITED_API defined, and your .so file gets tagged abi3.so. It works like magic for GIL-enabled builds.
But it never worked for free-threaded builds. Define Py_LIMITED_API and Py_GIL_DISABLED together, and the C headers silently break. Your extension fails to compile or crashes at runtime because PyObject layout differs between GIL-enabled and free-threaded interpreters.
abi3t solves this by making the PyObject structure opaque in the Stable ABI. Starting with Python 3.15, extensions targeting abi3t use new accessor APIs instead of field-level struct access. The ABI tag becomes abi3t and, if you compile for both ABIs, abi3.abi3t.
Key facts:
- Author: Petr Viktorin, Nathan Goldbaum
- Python version: 3.15+
- Status: Accepted (March 2026)
- Requires: PEP 703 (free-threaded build), PEP 793 (no-compile-needed), PEP 697 (per-instance defaults)
- Core change:
PyObjectis opaque; new C API replaces field-level access
The PyObject Opaque Restructuring
The single biggest change is that PyObject fields are no longer directly accessible in the Limited API. This means code like this — which works fine today on abi3 — will fail:
// BEFORE: accessing ob_refcnt directly
if (obj->ob_refcnt > 1) {
return Py_True;
}
This won’t compile with Py_LIMITED_API and Py_GIL_DISABLED both defined in 3.15. You need the accessor functions instead:
#include <Python.h>
#if PY_VERSION_HEX >= 0x030F00A0
#define Py_GIL_DISABLED
#endif
static int
check_refcount(PyObject *obj)
{
#if PY_VERSION_HEX >= 0x030F00A0
Py_ssize_t refcnt = _PyRefcnt_GetTotal(obj);
return refcnt > 1;
#else
return obj->ob_refcnt > 1;
#endif
}
The #if PY_VERSION_HEX guard lets you support both old and new interpreters. This is the pattern that all PEP 803 adopters will use.
Why this matters: If your extension manipulates refcounts (and most do, directly or indirectly through macro calls), you need to audit every place that touches object internals.
Common Pitfall: Direct Py_TYPE Access
Another frequent pattern that breaks is directly accessing ob_type:
// BEFORE: direct field access — will not compile with abi3t
PyTypeObject *tp = obj->ob_type;
The fix uses the proper accessor:
// AFTER: using the accessor macro
PyTypeObject *tp = Py_TYPE(obj);
// If you need to check the type, use isinstance or PyIs_Fast
if (PyLong_Check(obj)) {
long val = PyLong_AsLong(obj);
}
The error you’ll see if you don’t fix this:
error: member reference type 'PyObject *' (aka 'struct _object *')
is not a pointer; maybe you should use '->' or '...'
or cast the pointer to 'struct _object *'?
Compiling for Both ABIs
The PEP encourages a dual-compile strategy: build your extension against both abi3 and abi3t, producing two wheel tags from a single source tree. This keeps you compatible with both GIL-enabled and free-threaded interpreters.
Here’s a minimal pyproject.toml with setuptools that does both:
[build-system]
requires = ["setuptools>=68.0", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "myextension"
version = "0.1.0"
[tool.setuptools.ext-modules]
myextension = { sources = ["src/myextension.c"] }
And here’s how your build script produces both ABI wheels:
#!/usr/bin/env bash
set -euo pipefail
# Build 1: Standard abi3 (GIL-enabled)
python -m pip install --upgrade build
python -m build --wheel \
--config-setting="--define=PY_LIMITED_API=1"
# Build 2: abi3t (free-threaded)
python -m build --wheel \
--config-setting="--define=PY_LIMITED_API=1" \
--config-setting="--define=Py_GIL_DISABLED=1"
# The resulting wheel tags will be:
# myextension-0.1.0-cp315-cp315-abi3-manylinux_2_17_x86_64.whl
# myextension-0.1.0-cp315t-cp315t-abi3t-manylinux_2_17_x86_64.whl
The key is passing Py_GIL_DISABLED=1 as a compile definition. This activates the free-threaded header paths. Your setup.py or build hook needs to add both -DPY_LIMITED_API and -DPy_GIL_DISABLED to the compile command for the second wheel.
With setuptools, you can automate this using an extension-level flag:
# setup.py
from setuptools import setup, Extension
ext_gil = Extension(
"myextension",
sources=["src/myextension.c"],
define_macros=[("PY_LIMITED_API", "1")],
)
ext_notgil = Extension(
"myextension",
sources=["src/myextension.c"],
define_macros=[
("PY_LIMITED_API", "1"),
("Py_GIL_DISABLED", "1"),
],
)
setup(
name="myextension",
version="0.1.0",
ext_modules=[ext_gil, ext_notgil],
)
This produces two separate extension modules in the same package. pip’s resolver picks the right one based on the interpreter tag.
The New C API for Opaque PyObject
PEP 803 doesn’t just break old code — it introduces new APIs designed to replace the most common field-level patterns. Here’s a reference table of the most important changes:
| Old Pattern | New API | Notes |
|---|---|---|
obj->ob_refcnt |
_PyRefcnt_GetTotal(obj) |
Per-instance refcount isn’t directly exposed |
obj->ob_type |
Py_TYPE(obj) |
Macro, available since early Limited API |
obj->ob_base |
N/A | Internal only, never part of Stable ABI |
tp->tp_name |
PyType_GetName(tp) |
New accessor in 3.15 |
obj->ob_size |
PyLong_AsLong(obj) for int |
Use the type-specific accessor |
Let’s see these in action with a real example:
"""demo.c — Portable C extension for abi3 and abi3t."""
#include <Python.h>
#if PY_VERSION_HEX >= 0x030F00A0
#define Py_GIL_DISABLED
#endif
static PyObject *
demo_type_name(PyObject *self, PyObject *args)
{
PyObject *obj;
if (!PyArg_ParseTuple(args, "O", &obj)) {
return NULL;
}
// Works in both abi3 and abi3t
PyObject *name = PyType_GetName(Py_TYPE(obj));
if (name == NULL) {
return NULL;
}
// name is a Python string
return Py_NewRef(name);
}
static PyMethodDef DemoMethods[] = {
{"type_name", demo_type_name, METH_VARARGS,
"Return the type name of an object."},
{NULL, NULL, 0, NULL}
};
static struct PyModuleDef demomodule = {
PyModuleDef_HEAD_INIT,
.m_name = "demo",
.m_methods = DemoMethods,
};
PyMODINIT_FUNC
PyInit_demo(void)
{
return PyModule_Create(&demomodule);
}
Compile it for both ABIs and the result works on any Python 3.15+:
gcc -shared -fPIC -O2 \
-I/usr/include/python3.15 \
-DPY_LIMITED_API=1 \
-c demo.c -o demo.o
# For abi3 (GIL-enabled):
gcc -shared -o demo.cpython-315-x86_64-linux-gnu.so demo.o
# For abi3t (free-threaded):
gcc -shared -fPIC -O2 \
-I/usr/include/python3.15t \
-DPY_LIMITED_API=1 \
-DPy_GIL_DISABLED=1 \
-c demo.c -o demo_t.o
gcc -shared -o demo.cpython-315t-x86_64-linux-gnu.so demo_t.o
Wheel Tags and Filename Conventions
The PEP defines how wheels signal their ABI compatibility:
abi3— Standard Stable ABI (GIL-enabled only)abi3t— Free-threaded Stable ABIabi3.abi3t— Compiled for both ABIs (dual-tagged)
pip resolves these using the interpreter’s ABI tag. When a user installs on a free-threaded 3.15:
- pip sees
cp315tas the Python tag - It prefers
abi3tover bareabi3because the free-threaded interpreter can’t load GIL-only extensions safely - A dual-tagged
abi3.abi3twheel is accepted by either interpreter
For build tools, the recommendation is straightforward:
pip/ installers: preferabi3ton free-threaded interpreters,abi3otherwise- Build tools (scikit-build, meson): set
--plat-nameappropriately based on the target interpreter - CI pipelines: build both tags to reach the widest audience
If you’re using cibuildwheel, the configuration gets slightly more complex because you need to target both GIL and free-threaded interpreter variants:
# pyproject.toml for cibuildwheel
[tool.cibuildwheel]
archs = ["auto64"]
skip = ["pp*", "*musl*", "*i686"]
# Build for free-threaded interpreters on CPython 3.15+
[[tool.cibuildwheel.overrides]]
select = "*-cp315t-*"
environment = { PY_GIL_DISABLED = "1" }
[[tool.cibuildwheel.overrides]]
select = "*-cp316t-*"
environment = { PY_GIL_DISABLED = "1" }
This configuration ensures your CI produces wheels for both cp315 and cp315t variants.
Common Mistakes
Mistake 1: Assuming abi3t is a drop-in replacement
Wrong: Treating abi3t as just another ABI version.
# This won't work — abi3t requires source changes
# If your code accesses PyObject fields directly,
# it must be recompiled for abi3t separately.
abi3t is not ABI-compatible with abi3. An extension compiled against abi3 cannot load on a free-threaded interpreter, and vice versa. You must compile two separate artifacts.
Mistake 2: Forgetting the compile guard
Wrong:
// This will fail to compile on Python 3.15+ with free-threaded
#define PY_LIMITED_API 1
#include <Python.h>
// ... code that accesses PyObject fields ...
Right:
#include <Python.h>
// Guard the Limited API definition — it interacts with
// Py_GIL_DISABLED only in 3.15+
#if defined(Py_GIL_DISABLED) && PY_VERSION_HEX >= 0x030F00A0
// abi3t path — use new accessor APIs
#else
// Standard abi3 path
#endif
The error you get without the guard:
Python.h: fatal error: include/py/pymem.h: No such file or directory
Mistake 3: Not handling PyType_GetName return values
Wrong:
// Memory leak — PyType_GetName returns a new reference
PyObject *name = PyType_GetName(Py_TYPE(obj));
printf("Type: %s\n", PyUnicode_AsUTF8(name));
// name is leaked here
Right:
PyObject *name = PyType_GetName(Py_TYPE(obj));
if (name != NULL) {
printf("Type: %s\n", PyUnicode_AsUTF8(name));
Py_DECREF(name); // Don't forget this
}
Who Benefits from abi3t
PEP 803’s motivation section names several major projects:
- Cryptography — Ships 48 wheels per release. abi3t eliminates the need for per-version free-threaded builds, keeping the total manageable.
- Pydantic — Currently distributes 112 wheels for pydantic-core. The Stable ABI would let them reduce this dramatically on tier-2 platforms.
- SciPy — 60 version-specific wheels for the last release. Would benefit from Stable ABI to cut build overhead.
- Cython — The bindings generator is actively adding abi3t support in its build system.
- PyO3 (Rust) — The popular Rust bindings crate for Python is tracking abi3t support in this issue.
If your extension is a dependency of any of these projects, you’ll likely see abi3t wheels land in your dependency tree within a Python 3.15 release cycle.
Migration Checklist
Here’s a practical checklist for getting your extension ready:
- Audit field access — Search your codebase for
->ob_refcnt,->ob_type, and otherPyObjectfield patterns - Add version guards — Wrap old access patterns in
#if PY_VERSION_HEX >= 0x030F00A0conditionals - Update build config — Add
Py_GIL_DISABLEDas an optional compile definition - Dual-build in CI — Produce both
abi3andabi3twheel tags - Test on free-threaded — Install the
abi3twheel on a free-threaded 3.15 interpreter and run your test suite - Update metadata — Document abi3t support in your README and PyPI trove classifiers
Wrap-up
PEP 803 isn’t just a specification change — it’s an infrastructure update that determines whether your extension works on the next decade of Python interpreters. The PyObject opaque restructuring is the biggest breaking change since the Python 3 transition, but with version guards and the new accessor APIs, the migration path is straightforward.
Start by auditing field access in your codebase. Add the version guards. Build both ABI tags in your CI. By the time Python 3.15 ships, your extension will be ready.
The free-threaded future is here. abi3t makes sure your code ships to it.
References
- PEP 803: “abi3t”: Stable ABI for Free-Threaded Builds
- PEP 384: The Stable ABI
- PEP 779: Free-Threaded CPython
- PEP 703: Free-Threaded CPython — Implementation
- Python Limited API Documentation
- PEP 803 Discourse Discussion
- Python 3.14 Free-Threading Guide
- Cryptography 46.0.5 Wheels on PyPI
- PyO3 Py_GIL_DISABLED Guide
No comments yet. Be the first to leave a comment!