Free-threaded Python

Advanced40 minPython 3.14+

Install and detect the free-threaded build, run CPU-bound threads in parallel, and learn what changes for thread safety and extension modules when the GIL is gone.

What you will learn
  • State the support status of the free-threaded build accurately
  • Install a free-threaded interpreter and detect which build and GIL state are active
  • Speed up CPU-bound threads on the free-threaded build, and measure it fairly
  • Explain which operations on built-in types stay safe and which still need a lock
  • Check whether an extension module has turned the GIL back on
On this page

The free-threaded build of CPython is compiled without the global interpreter lock. Threads then run Python code truly in parallel, sharing memory as they always have. This lesson shows how to get it, what it changes, and what it does not.

Status

The status in Python 3.14 and 3.15:

  • The free-threaded build is officially supported since 3.14. What’s New in 3.14 calls this “the start of phase II where free-threaded Python is officially supported but still optional” (PEP 779).
  • It is optional and not the default. The standard download still has a GIL.
  • Making it the default (phase III) “is still undecided”.
  • Single-threaded code runs a little slower on it: on the pyperformance suite, about 1% slower on macOS aarch64 and 8% on x86-64 Linux, on average (free-threading HOWTO).

Getting it

The free-threaded interpreter is a separate executable whose name ends in t, such as python3.15t. It installs alongside the regular one.

  • uv: uv python install 3.15t, then uv run --python 3.15t script.py.
  • Windows: the Python install manager installs it with a t tag: py install 3.15t.
  • macOS: Requires version 3.15 or later the python.org installer now installs the free-threaded build by default, as a separate PythonT.framework. In 3.14 it was an optional checkbox.
  • From source: configure with --disable-gil.

Virtual environments work as usual: create one with python3.15t -m venv .venv-ft.

Which build am I on?

Two different questions: was this interpreter built without a GIL, and is the GIL off right now?

build_check.py
import sys
import sysconfig
free_threaded_build = sysconfig.get_config_var("Py_GIL_DISABLED") == 1
print("free-threaded build:", free_threaded_build)
print("GIL enabled now:", sys._is_gil_enabled())
Terminal window
python3.15 build_check.py
Output
free-threaded build: False
GIL enabled now: True
Terminal window
python3.15t build_check.py
Output
free-threaded build: True
GIL enabled now: False
Terminal window
python3.15t -X gil=1 build_check.py
Output
free-threaded build: True
GIL enabled now: True

-X gil=1 or the environment variable PYTHON_GIL=1 turns the GIL back on in a free-threaded build, which is handy for comparisons. The reverse is impossible: the default build stops with Fatal Python error: ... Disabling the GIL is not supported by this build. python -VV also reports free-threading build. sys._is_gil_enabled() was added in 3.13. The leading underscore marks it as CPython-specific.

CPU-bound threads, in parallel

Here is the program from Threads that you were told not to write: CPU-bound work in a thread pool. It takes the number of workers from the command line:

cpu_threads.py
import sys
from concurrent.futures import ThreadPoolExecutor
def count_primes(start, stop):
found = 0
for n in range(max(start, 2), stop):
if all(n % d for d in range(2, int(n**0.5) + 1)):
found += 1
return found
workers = int(sys.argv[1]) if len(sys.argv) > 1 else 4
starts = [0, 50_000, 100_000, 150_000]
stops = [s + 50_000 for s in starts]
with ThreadPoolExecutor(max_workers=workers) as pool:
total = sum(pool.map(count_primes, starts, stops))
print("primes below 200000:", total)
Output
primes below 200000: 17984

The answer is the same on every build. The time is not. One run on a 4-core Linux machine:

Terminal window
time python3.15 cpu_threads.py 1 # real 0.36s
time python3.15 cpu_threads.py 4 # real 0.33s
time python3.15t cpu_threads.py 1 # real 0.37s
time python3.15t cpu_threads.py 4 # real 0.17s

With the GIL, four threads are no faster than one. Without it, four threads take about half the time, and one thread is slightly slower than on the default build. Your numbers will differ, so measure on your own machine with your own workload. Run each case several times, and compare like with like: same Python version, same input, a quiet machine.

Thread safety without the GIL

The free-threaded build aims to keep Python-level behaviour the same. Built-in types such as list, dict and set use internal per-object locks, so concurrent use will not corrupt them or crash the interpreter. The thread safety guarantees page lists the details. For example, list.append is atomic:

append_threads.py
import threading
items = []
def add_many(tag):
for i in range(10_000):
items.append((tag, i)) # one append is atomic on both builds
threads = [threading.Thread(target=add_many, args=(t,)) for t in "abcd"]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(len(items), len(set(items)))
Output
40000 40000

What was never safe is still not safe. A read-modify-write such as count += 1 on a shared variable is several steps on any build. The GIL just made the bad interleaving rare. Without it, threads really run at the same time, and the bug shows up quickly:

unsafe_counter.py
import threading
count = 0
def bump():
global count
for _ in range(200_000):
count += 1
ts = [threading.Thread(target=bump) for _ in range(4)]
for t in ts:
t.start()
for t in ts:
t.join()
print(count)
Terminal window
python3.15 unsafe_counter.py
python3.15t unsafe_counter.py
Output
800000
281940

The second number changes on every run. On the default build 800000 is likely but not guaranteed. The fix is the one from Threads: a Lock around the update. Prefer your own threading.Lock to relying on the internal locks of built-in types, which protect single operations only.

Two more rules:

  • Sharing an iterator between threads is not safe: threads may see duplicate or missing elements. Requires version 3.15 or later Wrap it with threading.serialize_iterator, or decorate the generator function with @threading.synchronized_iterator.
  • Threads inherit context. On the free-threaded build, a new Thread starts with a copy of its creator’s context variables, so settings made with warnings.catch_warnings() or a decimal local context carry over. On the default build it starts with an empty context.

Extension modules

A C extension that has not declared free-threading support re-enables the GIL when it is imported, and Python prints a warning saying so. Your program still runs, but serially. After importing your dependencies, check sys._is_gil_enabled(). The community-maintained trackers at py-free-threading.github.io list which packages are ready. PYTHON_GIL=0 (or -X gil=0) forces the GIL off regardless. With an extension that is not ready, that is at your own risk.

Requires version 3.15 or later For extension authors, Python 3.15 adds a stable ABI for free-threaded builds, abi3t (PEP 803). Like the existing stable ABI, it lets one build serve later versions too, instead of one build per version such as cp315t.

Testing your code

Run your test suite on both builds. Concurrency bugs are timing-dependent, so also add tests that hammer shared objects from many threads at once, and repeat them. A green run on the default build tells you little about the free-threaded one.

When not to use the free-threaded build

Stay on the default build when your program is single-threaded, since on the free-threaded build it would only run a few per cent slower. Stay there when a dependency has not declared free-threading support, since it turns the GIL back on anyway. And stay there when your threads mostly wait on I/O: the GIL is released while waiting, so removing it gains little. Choose the free-threaded build for CPU-bound work that needs threads sharing large in-memory data, which processes or interpreters would have to copy, and test it on both builds.

Common pitfalls

Saying the GIL is gone. It is optional, in a separate build. The default build has it.

Assuming built-ins make your logic atomic. One append is atomic. if key not in d: d[key] = ... is two operations.

Benchmarking one run. Timings vary. Repeat, and compare against the default build at the same version.

Not noticing the GIL came back. One unprepared extension turns it on for the whole process.

ExerciseBenchmark both builds

Install 3.15t (for example with uv python install 3.15t), then run these four commands and record the real time of each:

Terminal window
time python3.15 cpu_threads.py 1
time python3.15 cpu_threads.py 4
time python3.15t cpu_threads.py 1
time python3.15t cpu_threads.py 4

Every run must print primes below 200000: 17984; that line is the only part with a fixed answer. On a machine with 4 or more cores (check with python3.15 -c "import os; print(os.cpu_count())"), the 4-worker time on python3.15t should be at most about two thirds of its 1-worker time, and on python3.15 the two times should be within about 10% of each other. Explain both results in one sentence each.

Show solution

On the default build the four threads must take turns holding the GIL to run bytecode, so the work is still done one thread at a time. On the free-threaded build each thread runs on its own core, so the elapsed time falls by up to the number of cores, minus the overhead of the build and of the thread pool. If 4 workers are not faster on python3.15t, check sys._is_gil_enabled(), and check how many cores the machine really has.

ExerciseFix the double computation

get_rate should call the expensive rate service once per currency, but this program calls it twice. Explain why, and fix it so it prints rate computations: 1 even when the expensive call is slow.

cache_race.py
import threading
cache = {}
computed = []
both_missed = threading.Barrier(2)
def get_rate(currency):
if currency not in cache: # check ...
both_missed.wait() # (force the unlucky timing)
computed.append(currency) # an expensive call
cache[currency] = 1.08 # ... then act
return cache[currency]
threads = [threading.Thread(target=get_rate, args=("EUR",)) for _ in "ab"]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print("rate computations:", len(computed))
Output
rate computations: 2
Show solution

Each dictionary operation is atomic, but not in followed by the assignment is check-then-act: both threads see the key missing before either stores it. On the free-threaded build this happens without any barrier, because both threads really run at once. Hold one lock across the check and the store:

cache_lock.py
import threading
import time
cache = {}
computed = []
cache_lock = threading.Lock()
def get_rate(currency):
with cache_lock:
if currency not in cache:
time.sleep(0.05) # the slow call, now safe
computed.append(currency)
cache[currency] = 1.08
return cache[currency]
threads = [threading.Thread(target=get_rate, args=("EUR",)) for _ in "ab"]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print("rate computations:", len(computed))
Output
rate computations: 1

One lock for the whole cache serialises every lookup. With many currencies, a lock per key or functools.cache around a function that is safe to call twice may suit you better.

ExerciseShare a generator safely

Requires version 3.15 or later Four threads should consume one generator of the numbers 0 to 999, each adding the values it receives to its own slot in totals. Use @threading.synchronized_iterator so the threads never advance the generator at the same time, then print the sum of totals next to sum(range(1000)). On 3.14, which lacks the decorator, write a small iterator class whose __next__ advances the generator while holding a threading.Lock.

499500 499500
Show solution
sync_generator.py
import threading
@threading.synchronized_iterator
def readings(count):
for n in range(count):
yield n
source = readings(1000)
totals = [0] * 4
def consume(slot):
for value in source:
totals[slot] += value
threads = [threading.Thread(target=consume, args=(i,)) for i in range(4)]
for thread in threads:
thread.start()
for thread in threads:
thread.join()
print(sum(totals), sum(range(1000)))
Output
499500 499500

Each slot of totals is written by one thread only, so totals[slot] += value needs no lock. Only the shared generator needs protection.

Summary

  • The free-threaded build is officially supported since 3.14, optional, and not the default. Phase III is undecided.
  • Its executable has a t suffix. sysconfig.get_config_var("Py_GIL_DISABLED") tells you the build, and sys._is_gil_enabled() tells you the GIL’s current state.
  • CPU-bound threads run in parallel on it. Measure, repeatedly, against the default build.
  • Built-in types will not be corrupted, but multi-step logic still needs a Lock. Races that were rare become common.
  • An extension without free-threading support turns the GIL back on, with a warning. Requires version 3.15 or later abi3t gives extension authors a stable ABI for free-threaded builds.
  • Next: multiple interpreters, parallelism with isolation instead of shared memory.