You can set up a buildable extension project from scratch, iterate on it quickly, read the filename of a built extension and know exactly what it is compatible with, and decide whether you want a version-specific wheel, an abi3 wheel, or a free-threaded wheel.
What a build actually is
Two commands, no magic:
- Compile your
.cfiles with the include directory that holdsPython.hon the search path. - Link them into a shared library with a platform-specific name, and on Windows, against
python3XX.lib.
On Linux and macOS you deliberately do not link against libpython: the extension is loaded into a process that already contains the interpreter's symbols, and they are resolved at load time. On Windows the loader requires every import to be resolved at link time, so you link the import library. Build tools handle this difference for you; know it because it explains error messages.
You can see everything the build needs from Python itself:
import sysconfig
print(sysconfig.get_paths()["include"]) # where Python.h lives
print(sysconfig.get_config_var("EXT_SUFFIX")) # e.g. .cp314-win_amd64.pyd
print(sysconfig.get_config_var("Py_GIL_DISABLED")) # 1 on a free-threaded build
print(sysconfig.get_platform())
The minimal project
mymod/
├── pyproject.toml
├── setup.py
├── src/
│ └── mymod.c
└── tests/
└── test_mymod.py
# pyproject.toml
[build-system]
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"
[project]
name = "mymod"
version = "0.1.0"
description = "Example C extension"
requires-python = ">=3.13"
[tool.pytest.ini_options]
testpaths = ["tests"]
PEP 517/518 metadata. The build backend is declared here; pip installs it into an isolated environment before building.
# setup.py -- still the right place for anything computed
import sysconfig
from setuptools import Extension, setup
# The Limited API and the free-threaded build are mutually exclusive today.
free_threaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED"))
setup(
ext_modules=[
Extension(
name="mymod", # must match PyInit_mymod
sources=["src/mymod.c"],
include_dirs=[], # extra headers
libraries=[], # e.g. ["z"] for -lz
library_dirs=[],
define_macros=[("MYMOD_VERSION", '"0.1.0"')],
extra_compile_args=[],
py_limited_api=not free_threaded,
),
],
)
A setup.py containing only setup() is not deprecated; running python setup.py directly is. Invoke it through pip or build.
Recent setuptools also supports declaring simple extensions purely in pyproject.toml, which is convenient when nothing needs to be computed:
[tool.setuptools]
ext-modules = [
{name = "mymod", sources = ["src/mymod.c"]}
]
The build/test loop
pip install -e . | Editable install. Python changes take effect immediately; C changes still require a rebuild. |
pip install -e . --no-build-isolation -v | The fast iteration command. Skips creating a fresh build venv each time and shows the compiler command lines. |
python -m build | Produces an sdist and a wheel in dist/. What you ship. |
pip install . --force-reinstall | Non-editable install for testing what users will get. |
Editing the .c file and re-running the tests without rebuilding. An editable install links the compiled artifact, not the source. Put the rebuild in your test command: pip install -e . --no-build-isolation -q && pytest.
Reading the output filename
The extension suffix encodes the compatibility contract:
| Filename | Meaning |
|---|---|
mymod.cp314-win_amd64.pyd | CPython 3.14 only, 64-bit Windows. Will not load on 3.13 or 3.15. |
mymod.cpython-314-x86_64-linux-gnu.so | The same contract on Linux. |
mymod.abi3.so / mymod.pyd (abi3) | Built against the Limited API; loads on every CPython from the declared minimum upward. |
mymod.cpython-314t-x86_64-linux-gnu.so | The t means the free-threaded build. A separate, incompatible ABI. |
And the wheel tags mirror it: mymod-0.1.0-cp314-cp314-win_amd64.whl (one Python version), mymod-0.1.0-cp313-abi3-win_amd64.whl (3.13 and later), mymod-0.1.0-cp314-cp314t-manylinux_2_28_x86_64.whl (free-threaded).
The Limited API and the stable ABI
By default, an extension is compiled against the full CPython API, which includes macros that read struct fields at fixed offsets. Those offsets change between minor versions, so the resulting binary is version-locked — a new wheel for every Python release.
The Limited API is a documented subset that avoids anything layout-dependent. Opt in before including the header:
#define Py_LIMITED_API 0x030A0000 /* minimum: Python 3.10 */
#include <Python.h>
The matching binary contract is the stable ABI, marketed as abi3: one wheel loads on every CPython from that minimum version forward. In setup.py that is py_limited_api=True on the Extension plus a wheel tag:
# setup.cfg
[bdist_wheel]
py-limited-api = cp310
| Full API | Limited API / abi3 | |
|---|---|---|
| Wheels to build and test | One per Python minor version × platform | One per platform |
| Works on a new Python release | Only after you rebuild | Immediately |
Fast macros (PyList_GET_ITEM, PyFloat_AS_DOUBLE) | Yes | No — real function calls instead |
Static types (PyTypeObject literals) | Yes | No — heap types via PyType_Spec only |
| Free-threaded builds | Yes | Not supported today |
Two honest caveats. First, Py_LIMITED_API constrains declarations, not semantics: a function whose behaviour on a NULL argument changed between versions will still surprise you, so test on every version you claim to support. Second, Python does not verify the claim — it will happily load a file named .abi3.so that uses non-stable API, and then crash.
Start without the Limited API. It is simpler, faster, and lets you use static types and the fast macros while you are learning. Move to abi3 when the maintenance cost of per-version wheels becomes real — and if you need free-threaded support, you are building separate wheels anyway.
Free-threaded builds
The free-threaded interpreter (Lesson 11) is a genuinely different ABI. What the build needs to know:
- Detect it in C with
#ifdef Py_GIL_DISABLED, and in Python withsysconfig.get_config_var("Py_GIL_DISABLED"). - You must build separate wheels (
cp314t). The stable ABI is not available. - On Windows, from Python 3.14 the build backend must define
Py_GIL_DISABLED=1explicitly when targeting the free-threaded interpreter; the compiler no longer infers it. Current setuptools does this, but if you drive the compiler yourself, add it. cibuildwheeland themanylinuximages support free-threaded targets; enabling them is a configuration flag, not a porting project.
Platform notes
Windows
You need the MSVC toolchain that matches your interpreter. CPython 3.14 from python.org is built with Visual Studio 2022 (MSC v.194x); install "Desktop development with C++" from the Visual Studio Build Tools. Then everything is ordinary: pip install -e . finds the compiler through setuptools' registry lookup. The common failure, error: Microsoft Visual C++ 14.0 or greater is required, means exactly what it says.
Useful MSVC flags via extra_compile_args: /O2 (default for release), /W4, /WX, /arch:AVX2. Note that MSVC does not accept the GCC spellings, so guard them:
import sys
warn_flags = ["/W4"] if sys.platform == "win32" else ["-Wall", "-Wextra"]
Linux
You need the development headers: python3-dev (Debian/Ubuntu) or python3-devel (Fedora/RHEL). The absence of them produces fatal error: Python.h: No such file or directory, which is the single most common build failure in the ecosystem. For redistributable wheels, build inside a manylinux container so the glibc symbol versions are old enough.
macOS
Xcode command line tools provide clang. Watch MACOSX_DEPLOYMENT_TARGET and universal2 (arm64 + x86_64) builds if you distribute.
Other build backends
setuptools is fine for a handful of C files. When the native side grows, two alternatives are worth knowing:
- meson-python
- PEP 517 backend driving Meson. Fast, good dependency detection, real incremental builds. Used by SciPy and NumPy.
- scikit-build-core
- PEP 517 backend driving CMake. The right answer when you are wrapping a C/C++ library that already builds with CMake.
Both are drop-in from the consumer's point of view — pip install . still works — because the interface is pyproject.toml's build-backend.
When the import fails
| Symptom | Cause |
|---|---|
ModuleNotFoundError after a successful build | The artifact is not on sys.path, or an editable install was not refreshed. Check python -c "import mymod; print(mymod.__file__)". |
ImportError: dynamic module does not define module export function (PyInit_x) | Entry point name does not match the extension name, or the symbol was not exported (missing PyMODINIT_FUNC, or static on the init function). |
ImportError: DLL load failed while importing x (Windows) | A dependent DLL is missing, or you built against a different Python. Diagnose with dumpbin /dependents or Dependencies.exe. |
undefined symbol: PyXxx (Linux) | Built against headers from one Python, loaded into another; or you used a private symbol that was removed. |
| Import warns that the GIL was re-enabled | Free-threaded build importing a module that did not declare Py_mod_gil. Lesson 11. |
| Segfault on first call | Almost always refcounting or an unchecked type. Rebuild against a debug interpreter and run again. |
Debugging natively
# Linux/macOS: run the whole interpreter under a debugger
gdb --args python -c "import mymod; mymod.crash()"
lldb -- python -c "import mymod; mymod.crash()"
# Address sanitizer, with Python's own allocator out of the way
CFLAGS="-fsanitize=address -g -O1" pip install -e . --no-build-isolation
PYTHONMALLOC=malloc ASAN_OPTIONS=detect_leaks=0 python -m pytest
# Reference-count checking needs a debug interpreter
python -X showrefcount -c "import mymod; mymod.f(1)"
On Windows, build a debug configuration and attach Visual Studio to python.exe; note that a debug extension must be loaded by python_d.exe, since the C runtime differs.
The code/hello/ directory in this course is a complete minimal project. Build and run it:
cd code/hello
python -m pip install -e . --no-build-isolation -v
python -c "import hello; print(hello.add_one(41)); print(hello.__file__)"
python -m pytest -q
Then, deliberately:
- Rename
PyInit_hellotoPyInit_helo, rebuild, and read the error. - Change the C source without rebuilding and confirm the old behaviour persists.
- Add
py_limited_api=Trueplus#define Py_LIMITED_API 0x030A0000and see which of your API calls stop compiling. - Print
sysconfig.get_config_var("EXT_SUFFIX")and match it against the file that was produced.
Further reading
- setuptools — Building Extension Modules. Every
Extension()keyword. - C API Stability. The authoritative statement on the Limited API and stable ABI.
- Python Packaging Guide — Binary Extensions.
- cibuildwheel. Builds and tests wheels for every platform and interpreter in CI, including free-threaded targets.
- pypackaging-native. A clear-eyed survey of why native packaging in Python is hard and what the current options are.