Free-Threaded Python Is Now Officially Supported: What PEP 779 Changes and What It Costs

Python 3.13 gave us something Python developers had wanted for decades: a build of CPython with no Global Interpreter Lock. It also gave us a reason not to use it in production — single-threaded code ran noticeably slower, extension support was patchy, and the whole thing carried an “experimental” label. Python 3.14 changes that calculus. Under PEP 779, free-threaded Python is now an officially supported build, the single-threaded penalty has shrunk to roughly 5–10%, and the specializing interpreter that makes the default build fast finally works in free-threaded mode too. This post walks through what actually changed, what it costs you, and how to decide whether your workload is ready to drop the GIL.

From experiment to supported build

The free-threaded build originated in PEP 703 and landed in Python 3.13 as an optional, explicitly experimental build target. What PEP 779 changes is the status, not the mechanism: the free-threaded build of Python is now supported and no longer experimental. That means the core team commits to keeping it viable going forward — it will not be removed without a proper deprecation schedule, and downstream packagers can ship it as a first-class option.

Two things are worth reading carefully in the announcement. First, this is “phase II”: free-threaded Python is supported but still optional. The default build you get from python.org and most distros remains GIL-enabled. Whether free-threading ever becomes the default or sole build — phase III — is explicitly undecided and depends on ecosystem adoption. Second, the change is a commitment signal to library authors. If you maintain a C extension, “will this build exist in three years” is no longer a reason to skip free-threading support.

Why free-threading got fast in 3.14

The headline reason to care is performance. In 3.13, running free-threaded meant paying a double tax: single-threaded code slowed down noticeably, and multi-threaded scaling was decent but not great. Python 3.14 attacks both problems.

The big internal win is that the specializing adaptive interpreter — the mechanism behind PEP 659 — is now enabled in free-threaded mode. Specialization is most of why modern CPython feels fast: the interpreter watches which operations run and rewrites frequently-executed bytecode into type-specialized fast paths. None of that worked in the 3.13 free-threaded build. With it enabled, along with a pile of smaller optimizations, single-threaded code in free-threaded mode now runs roughly 5–10% slower than the GIL-enabled build, depending on platform and compiler. That is down from penalties that made the build a non-starter for many workloads.

The implementation work also wrapped up: the PEP 703 design is complete, including the C API changes, and the temporary workarounds that shipped in 3.13 were replaced with permanent solutions. If you tried the 3.13 build and wrote it off, the 3.14 implementation is a different codebase in meaningful ways.

Getting and running the build

On most platforms you install free-threaded Python as a separate interpreter. With the official Windows and macOS installers, tick the free-threading option; on Linux, uv and pyenv both expose it as a distinct build (look for the 3.14t suffix). You can verify what you are running with a quick check:

import sysconfig

print(sysconfig.get_config_var("Py_GIL_DISABLED"))
# 1 means free-threaded, 0 (or None) means GIL-enabled

In your own C extension builds, the Py_GIL_DISABLED preprocessor variable is what gates the free-threading code paths. One porting note matters here: starting with 3.14 on Windows, the build backend must define Py_GIL_DISABLED explicitly when compiling for the free-threaded build — it is no longer inferred automatically by the C compiler. If you maintain an extension with a custom build script, this is the kind of thing that silently produces a GIL-dependent binary on Windows.

The extension story: one wheel, two builds

The practical blocker for free-threading adoption was never the interpreter — it was extension modules. A C extension written against the old assumptions mutates global state freely, and without the GIL serializing every bytecode, that state corrupts. The good news is that the porting recipe is well documented in the CPython free-threading how-to guide: eliminate mutable global state, use the per-interpreter or per-thread state slots, and guard anything genuinely shared.

An extension that does this correctly is “stable ABI compatible in spirit” — the same source supports both builds, and wheels for both land on PyPI tagged distinctly (free-threaded wheels carry a cp314t tag). At runtime an extension can also check the build it was loaded into:

import sys
import sysconfig

def report_build() -> str:
    gil_disabled = sysconfig.get_config_var("Py_GIL_DISABLED")
    if gil_disabled:
        return "free-threaded build (no GIL)"
    return f"GIL-enabled build (sys._is_gil_enabled: {sys._is_gil_enabled()})"

print(report_build())

That second check matters for a subtle case: even on the GIL-enabled build, code can opt into running without the GIL in limited ways, and even on the free-threaded build, importing a non-free-threading-aware extension re-enables the GIL as a compatibility fallback. sys._is_gil_enabled() tells you what is actually happening at runtime rather than what the build was compiled for.

What actually parallelizes well

Removing the GIL does not make Python code magically thread-safe or instantly parallel. It removes the serialization point; your own locking discipline becomes your problem. The workloads that win are the obvious CPU-bound ones. Here is a minimal demonstration using the same pure-Python function across threads:

import threading
import time

def cpu_bound(n: int) -> int:
    total = 0
    for i in range(n):
        total += i * i
    return total

def run_all(work: int, nthreads: int) -> float:
    threads = [
        threading.Thread(target=cpu_bound, args=(work,))
        for _ in range(nthreads)
    ]
    start = time.perf_counter()
    for t in threads:
        t.start()
    for t in threads:
        t.join()
    return time.perf_counter() - start

if __name__ == "__main__":
    WORK = 5_000_000
    serial = run_all(WORK * 4, 1)
    parallel = run_all(WORK, 4)
    print(f"serial:  {serial:.2f}s")
    print(f"4x parallel: {parallel:.2f}s  (speedup {serial / parallel:.1f}x)")

On the GIL-enabled build, the parallel leg runs at essentially the same wall-clock time as the serial one — four threads, no speedup, because only one thread executes bytecode at a time. On the free-threaded build with four cores, expect a speedup in the 3x-plus range for this kind of pure-Python arithmetic. The exact number depends on your cores, memory bandwidth, and how much time the interpreter spends in the internal locks that replaced the GIL.

Two categories need more nuance. I/O-bound thread pools gain little — they never needed the GIL gone, since threads release it during blocking calls anyway. And NumPy-style numeric work already ran outside the GIL for most heavy operations, so the gains concentrate in pure-Python code that used to be stuck: parsers, template engines, graph algorithms, simulation loops, or any mix of small dict/list operations too fine-grained to push into C.

New sharp edges in 3.14

Free-threading maturity in 3.14 came with concurrency-awareness changes that affect both builds. Two defaults differ between builds, and both are worth knowing about.

First, warnings control. The new -X context_aware_warnings flag (on by default in free-threaded builds) makes catch_warnings context-aware, so warning filters set in one thread stop leaking into others. On a GIL build with eight threads all mutating global filter state, “why did my warning suppression stop working” is a genuinely hard bug; this change removes an entire class of it.

Second, context inheritance. The new thread_inherit_context flag (also default-on for free-threaded builds) means threading.Thread now starts with a copy of the caller’s contextvars.Context. Anything that relies on context variables — decimal contexts, custom request-scoped state — behaves differently between builds unless you know the flag exists.

There is also one ecosystem-wide caveat: the experimental JIT shipped in the same release does not work with free-threaded builds. If your performance plan is “free-threading plus JIT,” you can only have one of those today.

Should you switch yet?

A reasonable decision path:

  • Hot single-process, CPU-bound Python (parsers, simulations, backtests): this is the target use case. Audit your C extension dependencies for free-threaded wheels; if they exist, benchmark. The 5–10% single-thread tax is small compared to near-linear scaling on N cores.
  • I/O-bound web services: little to gain. Asyncio or a GIL-enabled thread pool already overlap I/O fine. The tax is real and the benefit is not.
  • Libraries with C extensions: start porting now, not because users demand it today, but because both the community porting effort and the tooling around it are active, and the cost of catching up only grows.
  • Everything else: nothing changes. The GIL-enabled build remains the default, and phase III is explicitly undecided.

The free-threading project has crossed the hardest threshold: it is no longer an experiment that might be cancelled. From here, adoption is a matter of wheels, benchmarks, and the slow ecosystem work of making thread-safe code the norm. If you have a workload that has been multiprocessing-taxed for years, 3.14 is the release where threading becomes a real option again.

Leave a Reply

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