PEP 803: What abi3t Means for Python C Extension Authors

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: PyObject is 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 ABI
  • abi3.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:

  1. pip sees cp315t as the Python tag
  2. It prefers abi3t over bare abi3 because the free-threaded interpreter can’t load GIL-only extensions safely
  3. A dual-tagged abi3.abi3t wheel is accepted by either interpreter

For build tools, the recommendation is straightforward:

  • pip / installers: prefer abi3t on free-threaded interpreters, abi3 otherwise
  • Build tools (scikit-build, meson): set --plat-name appropriately 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:

  1. Audit field access — Search your codebase for ->ob_refcnt, ->ob_type, and other PyObject field patterns
  2. Add version guards — Wrap old access patterns in #if PY_VERSION_HEX >= 0x030F00A0 conditionals
  3. Update build config — Add Py_GIL_DISABLED as an optional compile definition
  4. Dual-build in CI — Produce both abi3 and abi3t wheel tags
  5. Test on free-threaded — Install the abi3t wheel on a free-threaded 3.15 interpreter and run your test suite
  6. 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

  1. PEP 803: “abi3t”: Stable ABI for Free-Threaded Builds
  2. PEP 384: The Stable ABI
  3. PEP 779: Free-Threaded CPython
  4. PEP 703: Free-Threaded CPython — Implementation
  5. Python Limited API Documentation
  6. PEP 803 Discourse Discussion
  7. Python 3.14 Free-Threading Guide
  8. Cryptography 46.0.5 Wheels on PyPI
  9. PyO3 Py_GIL_DISABLED Guide

No comments yet. Be the first to leave a comment!

Leave a Comment

Your email address will not be published. Required fields are marked *