You can explain what free-threading changed inside CPython, audit an extension for the specific patterns that stop being safe, declare support correctly, and choose between critical sections, PyMutex, and thread-local state for your own data.
Where things stand
PEP 703 proposed making the GIL optional. It shipped as an experimental build in Python 3.13 and, with PEP 779 accepted, became an officially supported but non-default build in Python 3.14. You get it as a separate interpreter: python3.14t on Unix, a separate installer option on Windows. The default python3.14 still has a GIL and everything in Lesson 10 applies unchanged.
From Python:
import sys, sysconfig
print(sysconfig.get_config_var("Py_GIL_DISABLED")) # 1 on a free-threaded build
print(sys._is_gil_enabled()) # False if the GIL is genuinely off right now
The second line is not redundant. A free-threaded interpreter can decide at runtime to turn the GIL back on, and the usual reason is your extension.
What changed inside
You do not need to implement any of this, but you need to know it happened, because each item invalidates an assumption that older extension code relies on.
- Biased reference counting
- Each object records an owning thread plus two counts: a local count the owner updates non-atomically, and a shared count other threads update atomically. This avoids an atomic read-modify-write on the overwhelmingly common same-thread case. Consequence: the refcount is no longer a single readable integer.
- Immortal objects
- Small ints, interned strings,
None,True,False, static types: refcount operations on them are no-ops (PEP 683, already true on GIL builds since 3.12). - Deferred reference counting
- Functions, code objects, modules and types skip refcount updates when pushed on the interpreter stack. Their true count is only knowable during a collection pause. Consequence:
Py_REFCNT()andsys.getrefcount()return numbers that are approximate and not reproducible. Any test or logic that asserts an exact refcount is invalid on this build. - mimalloc
- The thread-unsafe
pymallocwas replaced with mimalloc, which has per-thread heaps. Consequence: the separation between object allocation and raw allocation is now enforced rather than merely recommended. - Per-object locks and optimistic reads
list,dictandsetgained internal mutexes for mutation, with lock-free fast paths for reads that use a conditional refcount increment. Consequence: individual operations on builtin containers are atomic, but sequences of them are not, and borrowed references handed out by those containers are no longer safe.- Stop-the-world garbage collection
- The cycle collector pauses all threads at safe points. Consequence: detaching your thread state around long work is still mandatory, exactly as in Lesson 10 — a thread that never detaches and never executes bytecode stalls every collection.
Declaring support
When a free-threaded interpreter imports an extension that has not declared itself safe, it emits a warning and re-enables the GIL for the whole process. That is a deliberate safety default, and it means one unported dependency silently erases the benefit for everyone.
For a multi-phase module (Lesson 08), declaring support is one slot:
static PyModuleDef_Slot mymod_slots[] = {
{Py_mod_exec, mymod_exec},
#if PY_VERSION_HEX >= 0x030D0000
{Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
{0, NULL}
};
For a legacy single-phase module, it is a call inside PyInit_:
#ifdef Py_GIL_DISABLED
PyUnstable_Module_SetGIL(m, Py_MOD_GIL_NOT_USED);
#endif
You can also override from outside for experimentation: PYTHON_GIL=0 / PYTHON_GIL=1 in the environment, or -X gil=0 / -X gil=1.
Py_MOD_GIL_NOT_USED asserts that your module is thread-safe without the GIL. Nothing verifies it. Adding it to an unaudited module converts "slower than it could be" into "races and corruption under load". Do the audit below first.
The audit
1. Borrowed references from mutable containers
This is the largest category. Under the GIL, a borrowed reference was safe as long as you did not call anything that might run Python code. Without the GIL, another thread can drop the container's reference between your two instructions. Replace them:
| Unsafe | Use instead |
|---|---|
PyList_GetItem, PyList_GET_ITEM | PyList_GetItemRef |
PyDict_GetItem, PyDict_GetItemWithError | PyDict_GetItemRef |
PyDict_GetItemString | PyDict_GetItemStringRef |
PyDict_SetDefault | PyDict_SetDefaultRef |
PyWeakref_GetObject, PyWeakref_GET_OBJECT | PyWeakref_GetRef |
PyImport_AddModule | PyImport_AddModuleRef |
PyCell_GET | PyCell_Get |
These all return strong references, so remember the matching Py_DECREF. Note the return convention of the lookup variants: 1 found, 0 not found, -1 error, with the result written through an out-parameter.
Tuples remain safe because they are immutable — a borrowed item from a tuple you hold cannot be replaced. So do borrowed references into private objects you created and have not published to other threads.
2. Unlocked accessor macros
PyList_GET_ITEM, PyList_SET_ITEM, PySequence_Fast_GET_SIZE, PySequence_Fast_GET_ITEM and direct struct field reads do no locking whatsoever. They are legitimate on an object you just created and have not shared, and unsafe on anything reachable by another thread. Audit every use for "can another thread see this object?"
3. Sequences of operations are not atomic
/* Individually atomic, jointly a race: another thread can insert between them. */
if (PyDict_Contains(d, key) == 0) {
PyDict_SetItem(d, key, value);
}
Use PyDict_SetDefaultRef for that particular pattern, or wrap the sequence in a critical section.
4. Static mutable state
Caches, counters, lazily-initialized singletons and "last result" memos in C globals were implicitly protected by the GIL. They are not now. Three options, in order of preference:
- Initialize during module exec and never mutate. CPython holds a per-module lock during import, so single initialization at that point is safe with no work from you. Read-only afterwards is free.
- Make it per-thread with the TSS API from Lesson 10, or
thread_local. No synchronization needed, at the cost of duplicated memory. - Lock it with a
PyMutex.
5. Thread-unsafe third-party libraries
A library that is not reentrant was fine when the GIL serialized all calls into it. Now it needs an explicit global lock:
static PyMutex lib_lock = {0};
static int call_library(...) {
PyMutex_Lock(&lib_lock);
int rc = legacy_library_do_thing(...);
PyMutex_Unlock(&lib_lock);
return rc;
}
6. Allocator discipline
The separation is now enforced by mimalloc's segregated heaps:
PyObject_Malloc/PyObject_Free— only for Python object memory.PyMem_Malloc/PyMem_Free— for buffers, scratch space, anything that is not an object.PyMem_RawMalloc/PyMem_RawFree— when you may be detached, since these do not require an attached thread state.
Mixing them (allocating with one family and freeing with another) was always a bug; now it is a reliably fatal one.
The locking tools
PyMutex
static PyMutex m = {0}; /* must be zero-initialized; one byte */
PyMutex_Lock(&m);
...
PyMutex_Unlock(&m);
Added in 3.13. It is tiny, it is fast when uncontended, and critically it detaches the thread state while it waits, which removes the deadlock from Lesson 10 automatically. Never copy or move a PyMutex; its address is meaningful.
Critical sections
A critical section locks a specific Python object's internal mutex, giving you something close to the old GIL guarantee scoped to that object:
Py_BEGIN_CRITICAL_SECTION(dict);
PyObject *key, *value;
Py_ssize_t pos = 0;
while (PyDict_Next(dict, &pos, &key, &value)) {
... /* dict cannot be mutated by another thread here */
}
Py_END_CRITICAL_SECTION();
Py_BEGIN_CRITICAL_SECTION2(a, b); /* two objects, deadlock-free ordering */
...
Py_END_CRITICAL_SECTION2();
The semantics you must know:
- On GIL builds the macros compile to nothing, so the code is portable.
- They must be paired within the same C scope.
- The lock can be suspended. If the code inside blocks — on I/O, on another lock, on anything that detaches — the critical section is released and reacquired afterwards. So a critical section guarantees the object is not concurrently mutated only across code that does not block.
- Only the most recently entered critical section's locks are guaranteed held. Nesting two single-object sections does not give you both locks; use
Py_BEGIN_CRITICAL_SECTION2. Two is the maximum.
Use critical sections when you need to make a sequence of operations on a Python object atomic. Use PyMutex for your own C data.
Testing
- Write tests that hammer the same object from several threads with a barrier to maximize overlap. Single-threaded tests prove nothing here.
- Run under ThreadSanitizer. On x86 the hardware's strong memory ordering hides many bugs that appear immediately on ARM, so test on ARM if you can — Apple Silicon counts.
pytest-run-parallelandpytest-freethreadedexist specifically to run an existing suite concurrently.- Delete or rewrite any test that asserts an exact
sys.getrefcountvalue; it is meaningless on this build.
Shipping
Repeating the build facts from Lesson 09 because they are the ones people miss: free-threaded is a distinct ABI with a t suffix, the Limited API and abi3 are not available there, and you therefore ship separate cp314t wheels. cibuildwheel and the manylinux images support this; on Windows, from 3.14 the build backend must define Py_GIL_DISABLED=1 explicitly.
- Build and run your existing test suite under
python3.14twithout declaring support. It works — the GIL just gets re-enabled — and it flushes out ABI and compile problems first. - Grep for every entry in the borrowed-reference table and for mutable C statics. Fix them.
- Add multi-threaded stress tests for your public API.
- Only then add the
Py_mod_gilslot, and re-run everything, ideally under TSAN.
Install a free-threaded interpreter (on Windows the python.org installer offers it as an optional feature; on Unix, python3.14t from your distribution or from uv python install 3.14t) and compare:
import sys, sysconfig, threading, time, math
print(sysconfig.get_config_var("Py_GIL_DISABLED"), sys._is_gil_enabled())
def busy(n):
s = 0.0
for i in range(n): s += math.sqrt(i)
return s
for nthreads in (1, 2, 4):
ts = [threading.Thread(target=busy, args=(3_000_000,)) for _ in range(nthreads)]
t = time.perf_counter()
[x.start() for x in ts]; [x.join() for x in ts]
print(nthreads, round(time.perf_counter() - t, 3))
Run it on python3.14 and on python3.14t. Then import a C extension that has not been ported (many have not) and watch sys._is_gil_enabled() flip back to True — with a warning if you run with -W all.
Further reading
- C API Extension Support for Free Threading. The official checklist; short and essential.
- Python Free-Threading Guide, especially Porting Extension Modules. Community-maintained, practical, current.
- PEP 703 — Making the GIL Optional. The design document: biased refcounting, deferred refcounting, mimalloc, per-object locks.
- PEP 779 — Criteria for supported status for free-threaded Python.
- C API — Synchronization Primitives.
PyMutexand the critical-section macros. - Victor Stinner — Free Threading internals: PyMutex.