For more than two decades, the standard answer to "why doesn't adding threads make Python faster?" has been the GIL, the global interpreter lock that lets only one thread run bytecode at a time. Since Python 3.13, CPython also ships a free-threaded build (nicknamed "3.13t") that can run without the GIL. This article skips the theory. We installed that build on an Apple M4, measured a pure-Python CPU-bound task with 1, 2, 4 and 8 threads, watched what breaks when the GIL goes away, and worked out when it is worth using in a real project.
All numbers are real output, and the source code is in code/s7-56. Environment: Apple M4 (10 cores), 16 GB RAM, Python 3.13.16 (regular and free-threaded builds), plus 3.14.8 for comparison; these were the latest patch releases of both branches at publication time. This is an experiment on one machine and one kind of workload, not a general benchmark.
The problem: the GIL prevents threads from running in parallel
The GIL (Global Interpreter Lock) protects CPython's reference counts and internal structures from concurrent access. The price is that every Python thread has to take turns: a 10-core machine still uses a single core for pure Python code. As a result, Python projects have always split into two camps: threads for I/O waits (see asynchronous crawling with asyncio and httpx), and multiprocessing for heavy computation, meaning several processes that each get their own memory copy and their own interpreter.
This limit shows up in text preprocessing, batch scoring and CPU model inference, as discussed in serving AI models with FastAPI and inference optimization with ONNX. Multiple processes use RAM in proportion to the process count and must serialize data when passing it back and forth. The free-threaded build aims to let you use threads for these workloads too, over a single shared memory space.
What free-threaded Python is
PEP 703 proposed making the GIL optional. The "What's New in Python 3.13" documentation records the result: the free-threaded build exists since 3.13 but is experimental and not enabled by default, and the documentation warns of possible bugs and a significant single-thread performance penalty. It is a separate executable, usually named python3.13t, not a switch inside regular Python. Keep these points in mind:
- It is compiled with
--disable-gil; the official macOS and Windows installers can bundle this executable as an optional component. - On this build, the GIL can still be turned back on at runtime with the environment variable
PYTHON_GIL=1or-X gil=1, and turned off withPYTHON_GIL=0. - C extension modules must be compiled specifically for this build. Any module that does not declare support causes the GIL to be re-enabled as soon as it is imported.
According to the official HOWTO on free-threading, the right way to tell is to distinguish two things: the build (sysconfig.get_config_var("Py_GIL_DISABLED")) and the runtime state (sys._is_gil_enabled()). The sys.version string also says "free-threading build" explicitly.
Installing and verifying the free-threaded build
To try free-threaded Python, we installed the Python builds with uv python install 3.13.16 3.13.16+freethreaded 3.14.8+freethreaded (standalone builds that leave the system Python untouched). The scripts in code/s7-56 read the interpreter paths from the environment variables PY313, PY313T and PY314T (defaulting to python3.13, python3.13t and python3.14t on the PATH), so they do not depend on uv's install directory. The check_build.py script prints the information we need:
import sys, sysconfig, platform
print("version :", sys.version.split(" (")[0])
print("build :", platform.python_build()[0], "|", "free-threading" in sys.version)
print("Py_GIL_DISABLED:", sysconfig.get_config_var("Py_GIL_DISABLED"))
print("GIL enabled :", sys._is_gil_enabled())
print("sizeof(None):", sys.getsizeof(None), "bytes")Real output when running with python3.13 and then python3.13t (running again with PYTHON_GIL=0 gives the same result as the second block):
version : 3.13.16
build : main | False
Py_GIL_DISABLED: 0
GIL enabled : True
sizeof(None): 16 bytes
version : 3.13.16 experimental free-threading build
build : main | True
Py_GIL_DISABLED: 1
GIL enabled : False
sizeof(None): 32 bytesTwo details stand out. First, the free-threaded build turns the GIL off by itself even without the environment variable, because no module forces it back on. Second, sizeof(None) grows from 16 to 32 bytes: objects that are not tracked by the garbage collector have a larger header because reference counts are split per thread. The HOWTO documents exactly this detail, along with a note that the mimalloc allocator replaces pymalloc.
The experiment: a CPU-bound task with 1, 2, 4 and 8 threads
The task must be pure Python computation that calls no C library that already releases the GIL, otherwise the measurement cannot tell the builds apart. We chose to count integers below 1.2 million whose digit sum is divisible by 7, using a plain loop. There are 8 identical jobs in total, split evenly across 1, 2, 4 or 8 threads; each configuration is measured 3 times and we take the median wall time. The result is checked with an assert against a precomputed answer.
def work(n: int) -> int:
"""Count integers whose digit sum is divisible by 7 (plain Python loop)."""
c = 0
for i in range(n):
s, x = 0, i
while x:
s += x % 10
x //= 10
if s % 7 == 0:
c += 1
return c
# run(threads): split N_TASKS jobs across threading.Thread objects, time with time.perf_counter(),
# then assert that the sum of results == N_TASKS * EXPECTED (see cpu_bench.py)The run_bench.sh script runs this file on four configurations, two of them free-threaded. This is the real output (seconds, median of 3 runs):
3.13 gil {"python": "3.13.16", "gil": true, "wall_s": {"1": 1.87, "2": 1.85, "4": 1.82, "8": 1.86}}
3.13t PYTHON_GIL=1 {"python": "3.13.16 experimental free-threading build", "gil": true, "wall_s": {"1": 2.41, "2": 2.47, "4": 2.67, "8": 2.43}}
3.13t PYTHON_GIL=0 {"python": "3.13.16 experimental free-threading build", "gil": false, "wall_s": {"1": 2.46, "2": 1.34, "4": 0.62, "8": 0.56}}
3.14t PYTHON_GIL=0 {"python": "3.14.8 free-threading build", "gil": false, "wall_s": {"1": 1.86, "2": 0.89, "4": 0.52, "8": 0.47}}| Configuration | 1 thread | 2 threads | 4 threads | 8 threads | Speedup at 8 threads vs. 1 thread |
|---|---|---|---|---|---|
| 3.13 with GIL | 1.87 s | 1.85 s | 1.82 s | 1.86 s | about 1.0x |
| 3.13t, GIL re-enabled | 2.41 s | 2.47 s | 2.67 s | 2.43 s | about 1.0x |
| 3.13t, GIL off | 2.46 s | 1.34 s | 0.62 s | 0.56 s | about 4.4x |
| 3.14t, GIL off | 1.86 s | 0.89 s | 0.52 s | 0.47 s | about 4.0x |
Here is how to read the free-threaded results without overselling them. With the GIL, adding threads does nothing, exactly as the theory says. On the free-threaded build with the GIL off, the time falls almost linearly up to 4 threads (2.46 s down to 0.62 s) and then nearly flattens: 4.4x at 8 threads instead of 8x. The M4 has 10 cores made up of performance and efficiency cores, so linear speedup up to 8 threads is not a reasonable expectation; we did not isolate the cause, so we make no claim about it. Against the regular build at 8 threads, free-threaded 3.13t is about 3.3 times faster (1.86 s down to 0.56 s).
The machine was running other processes, so the measurements fluctuate (when it was busy, 3.13t at 4 threads once took 1.19 s); the table shows the run from when the machine was quietest, and the trend did not change.
The single-thread cost of free-threaded Python
The second and third rows of the table show the price of free-threaded Python. On this task, running 1 thread on 3.13t takes 2.41 to 2.46 s against 1.87 s for the regular build, about 29 to 32% slower. That is far above the average in the official documentation: according to the HOWTO, on the pyperformance suite the average single-thread overhead ranges from roughly 1% on macOS aarch64 to 8% on Linux x86-64. There is no contradiction: pyperformance averages many kinds of workloads, while our integer loop is a pure integer loop. We did not measure the cause separately, and this is why you should not conclude "free-threaded is 30% slower" for every program.
Two things can be verified from the table. One, 3.14t runs single-threaded in 1.86 s, nearly equal to regular 3.13 (1.87 s), so the gap narrows considerably in 3.14. Two, the free-threaded build only wins from 2 threads up: 3.13t at 2 threads (1.34 s) is already faster than the regular build (1.85 s).
The official free-threaded roadmap: from experimental to supported
PEP 703 splits the rollout into three phases. Phase I (from 3.13): the build is available but explicitly experimental. Phase II: the build is officially supported but still optional. Phase III: it becomes the default build, a decision left to a future PEP. PEP 779 sets the criteria for moving to phase II, including a hard limit of 15% on the performance cost (recorded when the PEP was written as roughly 10% on most platforms and about 3% on macOS) and a memory-cost target of about 20% (measured then at roughly 15 to 20%). The Steering Council accepted this PEP on June 16, 2025 for Python 3.14.
The practical consequence: in Python 3.13, free-threaded is experimental and the documentation advises caution; in 3.14 it is a supported build, and the word "experimental" disappears from sys.version. Phase III (making it the default) has not been decided.
Free-threaded Python exposes code without locks
With free-threaded Python, the bigger cost than performance is correctness. The GIL used to hide a great many concurrency bugs. To try this out, we wrote a shared counter: 8 threads, each incrementing the value 200,000 times, in one version without a lock and one with threading.Lock:
def worker(): # runs in 8 threads, N_ITER = 200_000
for _ in range(N_ITER):
if use_lock:
with lock:
c.value += 1 # with lock
else:
c.value += 1 # without lockReal output when running with regular 3.13 and then 3.13t with PYTHON_GIL=0 (the run_race.sh script):
# 3.13 with GIL
GIL enabled: True | expected 1600000
without lock: 1600000
with lock : 1600000
# 3.13t PYTHON_GIL=0
GIL enabled: False | expected 1600000
without lock: 286900
with lock : 1600000With the GIL, the unlocked counter was correct in this run, but that is not a guarantee: c.value += 1 consists of several bytecode steps (read, add, write) and can in principle be interrupted in the middle; the GIL only makes that very unlikely. With the GIL off, threads really do run in parallel and the counter loses more than 80% of the increments (286,900 out of 1,600,000), while the locked version stays exactly right.
The number of lost increments changes from run to run, but the conclusion does not: any shared state written from several threads needs a lock or a thread-safe structure.
The official documentation adds that built-in types such as dict, list and set use internal locks so their data structures are not corrupted, but it recommends using threading.Lock or other synchronization primitives rather than relying on those internal locks. It also notes that an iterator shared between threads can yield duplicate or missing elements, and that reading another thread's frame.f_locals can crash the interpreter.
C extension modules and free-threaded Python: the GIL comes back on its own
This is the most practical issue when applying free-threaded Python to AI and data work, because most heavy libraries (NumPy, pandas, PyTorch) are C modules. The mechanism: any module that does not declare the Py_mod_gil slot makes the interpreter re-enable the GIL on import, with a warning. We checked two cases with run_ext.sh. Case one is NumPy from PyPI: free-threaded cp313t wheels exist only up to NumPy 2.4.6, and from 2.5 onward only cp314t (and cp315t) remain, so we installed NumPy 2.4.6 into the 3.13t venv and NumPy 2.5.3 into the 3.14t venv. Case two is a minimal C extension written in the old single-phase initialization style that does not declare Py_mod_gil (legacy_ext.c, compiled with build_legacy.sh). The real output of ext_output.txt (with the <frozen importlib._bootstrap>:488: prefix of the warning removed for brevity):
# numpy in the 3.13t venv
before import: False
numpy 2.4.6 | after import: False
# numpy in the 3.14t venv
before import: False
numpy 2.5.3 | after import: False
# legacy C extension, default
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'legacy_ext', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.
before import: False
hello from the C extension | after import: True
# legacy C extension, PYTHON_GIL=0
before import: False
hello from the C extension | after import: FalseTwo lessons for free-threaded Python. One, these two NumPy versions do not turn the GIL back on. Two, a single undeclared module is enough to pull the whole process back into GIL mode: the "after import: True" line means every parallel benefit disappears without any error, just a warning that is easy to lose in log filtering. The PYTHON_GIL=0 flag forces the GIL off ("at your own risk") and should be used only after you have verified that the module is safe. In a real project, check sys._is_gil_enabled() at the end of startup and log or fail fast if the result is True.
Using free-threaded Python in a real service
If you decide to try the free-threaded build, create the venv with exactly 3.13t or 3.14t using a recent pip, and check that pip install really picks wheels tagged cp313t or cp314t (if there is no wheel, pip reports that no matching version was found or has to compile from source). Cross-check every C library against the community support tracker that the HOWTO links to, add a sys._is_gil_enabled() check at startup, and then measure with your own load instead of trusting this article's numbers.
For work that can be split up, the usual pattern is concurrent.futures.ThreadPoolExecutor in place of ProcessPoolExecutor; it pays off most when the threads share large read-only data (lookup tables, search indexes) that would otherwise be copied into each process. Work that is already fast thanks to C libraries that release the GIL, such as Polars, which is already multithreaded in its Rust layer, gives no reason to change builds. For I/O waits, asyncio is still lighter; comparing with virtual threads in Java 21 shows that each ecosystem solves the same problem in its own way.
Trade-offs: when not to use free-threaded Python
| Situation | Consider free-threaded? | Reason |
|---|---|---|
| Pure Python CPU work, splittable, many cores | Yes, try free-threaded and measure | Table above: from 2.46 s down to 0.56 s at 8 threads |
| Mostly single-threaded program | Do not use free-threaded | You pay the single-thread and memory costs with no benefit |
| Depends on many C modules without support | Not yet | The GIL turns itself back on and nothing runs faster |
| Legacy code that shares state without locks | Audit first | The counter lost more than 80% of its value in the experiment |
| Production service that needs stability | Prefer free-threaded 3.14 or later | 3.13 is still experimental per the documentation; 3.14 is a supported build |
| Tight RAM (small VPS) | Be careful | Larger objects and a different allocator; PEP 779 cites a memory increase of about 15 to 20% |
One important limit of this free-threaded experiment: we measured only one task, on one machine, and we did not measure peak memory. For an AI service running on a small VPS like the one described in the end-to-end RAG chatbot project, try free-threaded Python on a staging copy and measure RSS and p95 latency before deciding. A model running on a GPU gains nothing from removing the GIL, because the computation happens in a C or CUDA library.
Common mistakes
- Trusting
sys.versionand forgetting to check the runtime state: a free-threaded build may still be running with the GIL on. - Ignoring the warning when importing an unsupported module: the GIL comes back, you measure no speedup, and you wrongly conclude that free-threaded Python is useless.
- Benchmarking with a task that calls libraries that already release the GIL: both builds give the same result, so the measurement cannot distinguish anything.
- Using
PYTHON_GIL=0to "force speed": this bypasses the module's safety warning and easily leads to data corruption that is hard to reproduce.
Checklist
- Confirm you really have the free-threaded build:
Py_GIL_DISABLEDis 1 andsys._is_gil_enabled()isFalseat the end of startup. - Measure the real task with 1, 2, 4 and 8 threads and compare against
ProcessPoolExecutorbefore changing the architecture. - Audit shared state (locks or queues) and check every library that contains a C module; run with
-W errorso the warning about the GIL coming back becomes an error. - Measure peak memory and p95 latency before and after switching.
Setup to rerun: set PY313, PY313T and PY314T as above, create .venv313t (numpy==2.4.6) and .venv314t (numpy==2.5.3), then run check_build.py, run_bench.sh, run_race.sh and run_ext.sh in code/s7-56. The files bench_output.txt, race_output.txt, check_output.txt and ext_output.txt are the output pasted above.
Series wrap-up
This is the last article in the "Python, AI & Data" series. We went from Python environments for data and AI projects and crawling data with Python, through machine learning, LLMs, RAG and agents, to MLOps with MLflow, monitoring models in production and the end-to-end data pipeline. The final article is about method more than technology: free-threaded Python is genuinely promising, but it is only worth adopting once you have measured on real load, checked every dependency and reviewed all shared state. If you need an experienced team to bring AI services into stable production, contact Sudo.



