How to Use Free-Threaded Python 3.15 to Run Threads in Parallel and Fix the Race Conditions the GIL Was Hiding
Install the standard and free-threaded builds of Python 3.15 side by side with uv, measure a 9.7 times thread speedup on a 14-thread machine, then fix the race conditions the GIL had been hiding.
Python has had threads for a very long time, and for almost as long it has had a rule that surprises newcomers: in the standard CPython interpreter, only one thread can run Python code at any instant. A lock called the global interpreter lock, or GIL, enforces that rule. Programs that mostly wait (for a web server, a database, a disk) never notice, because a waiting thread does not need the lock. Programs that mostly compute notice immediately: eight threads take turns, and the work finishes no sooner than it would with one thread.
Table Of Content
- What you will build
- What you need before you start
- The ideas you need, in plain language
- A thread is a second line of execution inside the same program
- The GIL lets one thread run Python code at a time
- A race condition is a bug that depends on timing
- Where free threading stands today
- Step 1: Install the standard and free-threaded builds side by side
- 1.1 Check which interpreter you are really running
- The third state: a free-threaded build with the GIL turned back on
- Step 2: Measure what the GIL costs on CPU-bound work
- 2.1 The benchmark harness
- 2.2 The CPU-bound job
- 2.3 Run it three ways
- Step 3: Find out when free threading does not help
- 3.1 Waiting is already parallel
- 3.2 C code that releases the GIL is already parallel
- 3.3 Allocation-heavy Python scales less
- 3.4 The rule of thumb
- Step 4: Meet the bug the GIL was hiding
- 4.1 Write a counter that has always worked
- 4.2 Why one += can lose an update
- 4.3 Two more races, and one thing that is safe
- 4.4 Prove it: make the standard build switch threads more often
- Step 5: Fix the races
- 5.1 Which fix should you choose?
- Step 6: Mind the behavior changes that come with the build
- Step 7: Check that your dependencies keep the GIL off
- 7.1 The survey script
- 7.2 What the survey tells you
- Step 8: Keep it from regressing: test on both builds
- 8.1 A test that fails only without the GIL
- 8.2 A guard for the GIL itself
- 8.3 Add the free-threaded build to your CI
- Confirm everything works end to end
- Common mistakes and how to recover
- The free-threaded build is not faster
- The numbers change from run to run
- A test passes on one build and fails on the other
- Sharing one iterator between threads
- Where to go next
- Sources
Python 3.13 introduced an optional build of the interpreter with the GIL removed, and Python 3.14 made that build officially supported. In this tutorial you will install it next to the normal interpreter, measure exactly what changes, and learn the part that surprises almost everyone: thread code that has run correctly for years on the standard build can start losing data the moment the lock goes away.
Every number below comes from the scripts printed in this article, run on one Windows 11 machine with 14 logical processors (an Intel Core Ultra 5 235), using Python 3.15.1 and Python 3.14.9 installed by uv 0.13.5 on 22 October 2026. Your timings will differ. Read the shape of each result, not the last digit.
What you will build
By the end you will have:
- Two Python 3.15.1 interpreters side by side, a standard one and a free-threaded one, plus a script that tells you which is which.
- A small benchmark harness that runs the same work on 1, 2, 4, 8 and 14 threads, with a checksum that proves the answer never changes.
- Measurements of three kinds of work: pure Python computation (where free threading wins big), waiting and C code that already lets go of the GIL (where it changes nothing), and allocation-heavy Python (where it helps less).
- A race-condition lab in which a counter that always worked loses more than 80 percent of its increments, plus two fixes and what each one costs.
- A survey script that shows which popular packages install on the free-threaded build and keep the GIL off, and a pytest guard that fails when one does not.
What you need before you start
- Operating system: Windows, macOS or Linux. The commands are the same everywhere. I ran everything on Windows 11.
- Python knowledge: you can run a script, you have seen
import, and you know roughly whatthreading.Threaddoes. Everything else is explained when it first appears. - uv: a Python version and package manager from Astral. Install it by following the uv installation guide, then check it with
uv --version. I used 0.13.5. - Disk space: about 500 MB. Each interpreter takes about 68 MB once installed, and the dependency survey in Step 7 creates two environments of roughly 150 MB each.
- Time: plan on an hour or two. The benchmarks take from a few seconds to about a minute each.
- A folder for the scripts: put every script from this article in one folder, because several of them import
benchkit.pyorhitcounter.py.
The ideas you need, in plain language
A thread is a second line of execution inside the same program
A thread is an independent line of execution inside one program. All the threads of a program share the same memory. That sharing is what makes threads convenient (they can all see the same list) and what makes them dangerous (they can all change the same list at the same moment).
The GIL lets one thread run Python code at a time
The Python glossary defines the lock like this: “The mechanism used by the CPython interpreter to assure that only one thread executes Python bytecode at a time. This simplifies the CPython implementation by making the object model (including critical built-in types such as dict) implicitly safe against concurrent access.” Bytecode is the low-level instruction list that Python compiles your code into. The important consequence is practical: a thread that is waiting does not need the lock, so waiting threads overlap, while threads that are computing must queue for it.
The free-threaded build removes that queue. The glossary calls the result free threading, “A threading model where multiple threads can run Python bytecode simultaneously within the same interpreter.” The language stays the same. What changes is that nothing serializes your threads anymore, and that includes your mistakes.
A race condition is a bug that depends on timing
A race condition is a bug whose outcome depends on exactly when threads happen to run relative to each other. The classic form is a read-modify-write: a thread reads a value, works out a new one and writes it back, and another thread does the same in between, so one of the two updates vanishes. You will reproduce this on purpose in Step 4.
Where free threading stands today
PEP 703 proposed making the GIL optional, and its acceptance came with three phases of development. PEP 779 describes them: phase I is “making the free-threaded (GIL-less) Python build available but explicitly experimental,” while “Phase II would make the free-threaded build officially supported but still optional, and phase III would make the free-threaded build the default.” The What’s New in Python 3.14 page announces that phase II has begun: “The free-threaded build of Python is now supported and no longer experimental. This is the start of phase II where free-threaded Python is officially supported but still optional.” The same page is careful about what comes next: “Any decision to transition to phase III, with free-threading as the default or sole build of Python is still undecided, and dependent on many factors both within CPython itself and the community.”
In practice that means you opt in, you keep the standard build as a fallback, and you test your own workload instead of trusting a headline number. That is exactly what the rest of this tutorial does.
Step 1: Install the standard and free-threaded builds side by side
You need two interpreters of the same version so that any difference you measure comes from the build, not from a different release of Python. uv can install both. Its documentation says “uv supports discovering and installing free-threaded Python variants in CPython 3.13+.” A t after the version number asks for the free-threaded variant.
uv python install 3.15 3.15t
On my machine uv printed this:
Downloading cpython-3.15.1+freethreaded-windows-x86_64-none (download) (23.4MiB)
Downloading cpython-3.15.1-windows-x86_64-none (download) (23.3MiB)
Downloaded cpython-3.15.1+freethreaded-windows-x86_64-none (download)
Downloaded cpython-3.15.1-windows-x86_64-none (download)
Installed 2 versions in 5.71s
+ cpython-3.15.1+freethreaded-windows-x86_64-none (python3.15t.exe)
+ cpython-3.15.1-windows-x86_64-none (python3.15.exe)
Read it line by line. uv downloaded two builds of 3.15.1 of about 23 MiB each, the one with +freethreaded in its name and the standard one. Then it printed Installed 2 versions and, in parentheses, the launcher it created for each: python3.15t.exe for the free-threaded build and python3.15.exe for the standard one (on Windows the launchers end in .exe). From now on python3.15 means the standard build and python3.15t means the free-threaded build.
If your shell cannot find those names, the uv documentation has the fix: “If ~/.local/bin is not in your PATH, you can add it with uv python update-shell.” Open a new terminal afterwards.
You can also get the free-threaded build without uv. The free-threading HOWTO says “Starting with Python 3.13, the official macOS and Windows installers optionally support installing free-threaded Python binaries,” and the What’s New in Python 3.15 page adds that “The official macOS binaries now install free-threading support by default.”
1.1 Check which interpreter you are really running
It is surprisingly easy to benchmark the wrong interpreter, so write a three-line script that tells you which one you have. Save this as whoami.py:
# whoami.py
import sys
import sysconfig
print("version :", sys.version.split(" (")[0])
print("free-threading build :", bool(sysconfig.get_config_var("Py_GIL_DISABLED")))
print("GIL enabled now :", sys._is_gil_enabled())
print("threads inherit ctx :", bool(sys.flags.thread_inherit_context))
Run it three ways. The first command uses the standard build, the second uses the free-threaded build, and the third uses the free-threaded build with the GIL switched back on (more on that flag in a moment):
python3.15 whoami.py
python3.15t whoami.py
python3.15t -X gil=1 whoami.py
Standard build:
version : 3.15.1
free-threading build : False
GIL enabled now : True
threads inherit ctx : False
Free-threaded build:
version : 3.15.1 free-threading build
free-threading build : True
GIL enabled now : False
threads inherit ctx : True
Free-threaded build with the GIL forced on:
version : 3.15.1 free-threading build
free-threading build : True
GIL enabled now : True
threads inherit ctx : True
Each line answers a different question. The version string contains the words free-threading build only on the free-threaded interpreter, and the HOWTO confirms it: python -VV and sys.version contain “free-threading build”. The second line asks whether this build supports free threading at all. The HOWTO says to use sysconfig.get_config_var("Py_GIL_DISABLED") for that: “If the variable is set to 1, then the build supports free threading. This is the recommended mechanism for decisions related to the build configuration.”
The third line asks a different question: is the GIL actually off in this process right now? That is sys._is_gil_enabled(). The sys documentation says “Return True if the GIL is enabled and False if it is disabled,” and notes “It is not guaranteed to exist in all implementations of Python.” The leading underscore looks like a warning, but the function is documented and available since Python 3.13. The fourth line is the thread_inherit_context flag. It differs between the two builds, and Step 6 explains why it matters.
The third state: a free-threaded build with the GIL turned back on
The third run above is the useful one for experiments. The HOWTO explains: “Free-threaded builds of CPython support optionally running with the GIL enabled at runtime using the environment variable PYTHON_GIL or the command-line option -X gil.” So -X gil=1 (or PYTHON_GIL=1) gives you the same binary with the lock back, which lets you separate the effect of the lock from the effect of the build. You will use it in Steps 2 and 4.
Common mistake: a virtual environment remembers the interpreter that created it. If you create one with python3.15 -m venv .venv you get the standard build, whatever you install into it later. Use uv venv --python 3.15t when you want a free-threaded environment.
Step 2: Measure what the GIL costs on CPU-bound work
Work is CPU-bound when the time goes into computing rather than waiting. That is the kind of work the GIL hurts, so it is the right first test. You need two pieces: a harness that runs the same work on different numbers of threads and times it, and a job to run.
2.1 The benchmark harness
Save this as benchkit.py. It splits a fixed amount of work into 56 equal chunks and hands them to a ThreadPoolExecutor, a ready-made pool of worker threads. It repeats the run for 1, 2, 4 and 8 workers and for as many workers as your machine has processors, and prints the best of three timings for each:
# benchkit.py
import os
import sys
import time
from concurrent.futures import ThreadPoolExecutor
CHUNKS = 56 # divides evenly by 1, 2, 4, 8 and 14 workers
def describe():
kind = "free-threading build" if "free-threading" in sys.version else "standard build"
return f"Python {sys.version.split()[0]} {kind}, GIL enabled: {sys._is_gil_enabled()}"
def sweep(work, repeats=3):
"""Run CHUNKS pieces of work on 1, 2, 4, 8 and all-CPU threads; print best-of-N times."""
cpus = os.process_cpu_count() or 1
print(describe())
print("workers seconds speedup")
base = None
answers = set()
for workers in sorted({1, 2, 4, 8, cpus}):
best = float("inf")
for _ in range(repeats):
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as pool:
answers.add(sum(pool.map(work, range(CHUNKS))))
best = min(best, time.perf_counter() - start)
base = base or best
print(f"{workers:>7} {best:>7.3f} {base / best:>6.2f}x")
assert len(answers) == 1, "the answer changed with the thread count"
print("same answer for every thread count:", answers.pop())
Four details are worth understanding. First, 56 chunks divide evenly by 1, 2, 4, 8 and 14 workers, so no worker is left idle at the end on my machine. Second, the harness keeps the fastest of three runs, because the fastest run is the one least disturbed by whatever else the computer was doing. Third, os.process_cpu_count() returns the number of logical processors the process may use; the os documentation describes it as the number of logical CPUs usable by the calling thread of the current process. Fourth, the assert at the end is a safety net: every thread count must produce the same checksum, otherwise the benchmark is measuring broken code.
2.2 The CPU-bound job
Now the work itself. Save this as cpu_bench.py:
# cpu_bench.py
from benchkit import sweep
STEPS = 300_000
def work(seed):
x = seed
total = 0
for _ in range(STEPS):
x = (x * 1103515245 + 12345) & 0x7FFFFFFF
total += x & 0xFF
return total
sweep(work)
Each call runs a tiny pseudo-random number generator for 300,000 steps and adds up a piece of each result. The exact formula does not matter. What matters is that it is pure Python arithmetic, with no input or output and no call into a C library that could release the lock. Think of it as a stand-in for any loop in your own code that parses, scores or validates records.
2.3 Run it three ways
python3.15 cpu_bench.py
python3.15t cpu_bench.py
python3.15t -X gil=1 cpu_bench.py
Standard build:
Python 3.15.1 standard build, GIL enabled: True
workers seconds speedup
1 1.003 1.00x
2 1.358 0.74x
4 1.336 0.75x
8 1.337 0.75x
14 1.329 0.75x
same answer for every thread count: 2142007808
Free-threaded build:
Python 3.15.1 free-threading build, GIL enabled: False
workers seconds speedup
1 1.022 1.00x
2 0.604 1.69x
4 0.287 3.56x
8 0.157 6.51x
14 0.105 9.74x
same answer for every thread count: 2142007808
Free-threaded build with the GIL forced on:
Python 3.15.1 free-threading build, GIL enabled: True
workers seconds speedup
1 1.049 1.00x
2 1.564 0.67x
4 1.511 0.69x
8 1.377 0.76x
14 1.330 0.79x
same answer for every thread count: 2142007808
The columns are the number of worker threads, the best time in seconds, and the speedup compared with one worker on the same interpreter. A speedup of 2.00x means twice as fast. I also ran the same script on the free-threaded build of Python 3.14.9, and the table below puts all four runs side by side:
| Workers | Standard 3.15.1 | Free-threaded 3.15.1 | Free-threaded 3.15.1, GIL forced on | Free-threaded 3.14.9 |
|---|---|---|---|---|
| 1 | 1.003 s (1.00x) | 1.022 s (1.00x) | 1.049 s (1.00x) | 1.119 s (1.00x) |
| 2 | 1.358 s (0.74x) | 0.604 s (1.69x) | 1.564 s (0.67x) | 0.632 s (1.77x) |
| 4 | 1.336 s (0.75x) | 0.287 s (3.56x) | 1.511 s (0.69x) | 0.340 s (3.29x) |
| 8 | 1.337 s (0.75x) | 0.157 s (6.51x) | 1.377 s (0.76x) | 0.171 s (6.53x) |
| 14 | 1.329 s (0.75x) | 0.105 s (9.74x) | 1.330 s (0.79x) | 0.118 s (9.46x) |
Here is how to read the results.
- The standard build gets slower with more threads. One worker finished in 1.003 seconds. Fourteen workers needed 1.329 seconds, about a third longer, for exactly the same work. The threads cannot run Python code at the same time, and in this test the extra threads only add overhead.
- The same binary with the GIL forced on behaves the same way. The free-threaded build with
-X gil=1also slows down (0.79x at 14 workers). That tells you the lock is responsible, not some other difference between the builds. - The free-threaded build scales. It went from 1.022 seconds with one worker to 0.105 seconds with fourteen, a speedup of 9.74x. That is about 70 percent of the ideal 14x. I did not investigate where the rest goes, so I will not guess at a reason.
- The single-thread price is small here. One worker took 1.022 seconds on the free-threaded build and 1.003 seconds on the standard build, about 2 percent more. The HOWTO gives the general figure: “On the pyperformance benchmark suite, the average overhead ranges from about 1% on macOS aarch64 to 8% on x86-64 Linux systems.” The Python 3.14 notes say the penalty is “now roughly 5-10%, depending on the platform and C compiler used.” Step 3 shows a workload where my measurement is higher than my loop’s 2 percent.
- Python 3.14 behaves the same way. The 3.14.9 free-threaded build reached 9.46x at 14 workers, so none of this is specific to 3.15.
Common mistake: benchmarking with too little work. If each chunk finishes in a millisecond, you are timing thread start-up instead of your job. Aim for chunks that take tens of milliseconds, as these do.
Step 3: Find out when free threading does not help
Before you move a project to the free-threaded build, find out whether your workload is the kind that benefits. Three more benchmarks show the boundary.
3.1 Waiting is already parallel
Save this as io_bench.py. Each task sleeps for a tenth of a second, which stands in for waiting on a network reply:
# io_bench.py
import time
from concurrent.futures import ThreadPoolExecutor
from benchkit import describe
TASKS = 28
def fake_request(_):
time.sleep(0.1) # stands in for waiting on a network reply
return 1
print(describe())
for workers in (1, 14):
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as pool:
done = sum(pool.map(fake_request, range(TASKS)))
print(f"{workers:>2} workers: {time.perf_counter() - start:.2f}s for {done} tasks")
python3.15 io_bench.py
python3.15t io_bench.py
Standard build:
Python 3.15.1 standard build, GIL enabled: True
1 workers: 2.82s for 28 tasks
14 workers: 0.21s for 28 tasks
Free-threaded build:
Python 3.15.1 free-threading build, GIL enabled: False
1 workers: 2.82s for 28 tasks
14 workers: 0.21s for 28 tasks
The results are identical. Twenty-eight tasks that each wait 0.1 seconds take 2.82 seconds on one worker and 0.21 seconds on fourteen, on both builds. A sleeping thread does not hold the lock, so the standard build already overlapped the waits. If your threads mostly wait, free threading buys you nothing.
3.2 C code that releases the GIL is already parallel
Some work happens inside C libraries that let go of the lock on purpose. The hashlib documentation is explicit about one example: “To allow multithreading, the Python GIL is released while computing a hash supplied more than 2047 bytes of data at once in its constructor or .update method.” Save this as hash_bench.py:
# hash_bench.py
import hashlib
from benchkit import sweep
DATA = bytes(range(256)) * 16384 # 4 MiB
ROUNDS = 8
def work(seed):
digest = hashlib.sha256()
for _ in range(ROUNDS):
digest.update(DATA) # hashlib releases the GIL while it hashes this much data
return digest.digest()[0]
sweep(work)
python3.15 hash_bench.py
python3.15t hash_bench.py
| Workers | Standard 3.15.1 | Free-threaded 3.15.1 |
|---|---|---|
| 1 | 0.403 s (1.00x) | 0.404 s (1.00x) |
| 2 | 0.206 s (1.96x) | 0.205 s (1.97x) |
| 4 | 0.106 s (3.82x) | 0.107 s (3.79x) |
| 8 | 0.059 s (6.84x) | 0.059 s (6.83x) |
| 14 | 0.035 s (11.37x) | 0.037 s (10.79x) |
The standard build reaches 11.37x at 14 workers and the free-threaded build 10.79x. No gain, because the standard build was already hashing in parallel: the work runs in C while the GIL is released. Any library that releases the lock during its heavy work behaves the same way, so it is worth checking the libraries you rely on. Free threading helps the Python code between such calls.
3.3 Allocation-heavy Python scales less
Real programs create and discard many small objects. Save this as alloc_bench.py. Each chunk builds 200,000 short strings, counts them in a dictionary and sorts the counts:
# alloc_bench.py
from benchkit import sweep
WORDS = 200_000
def work(seed):
x = seed
words = []
for _ in range(WORDS):
x = (x * 1103515245 + 12345) & 0x7FFFFFFF
words.append("w" + str(x % 5000))
counts = {}
for word in words:
counts[word] = counts.get(word, 0) + 1
top = sorted(counts.items(), key=lambda item: (-item[1], item[0]))[:10]
return top[0][1]
sweep(work)
python3.15 alloc_bench.py
python3.15t alloc_bench.py
| Workers | Standard 3.15.1 | Free-threaded 3.15.1 |
|---|---|---|
| 1 | 1.961 s (1.00x) | 2.218 s (1.00x) |
| 2 | 3.015 s (0.65x) | 2.002 s (1.11x) |
| 4 | 2.928 s (0.67x) | 1.076 s (2.06x) |
| 8 | 2.771 s (0.71x) | 0.773 s (2.87x) |
| 14 | 2.895 s (0.68x) | 0.945 s (2.35x) |
This is a more sobering picture. On the free-threaded build the best result was 2.87x with 8 workers, and using all 14 was slower (0.945 seconds against 0.773). With one worker the free-threaded build took 2.218 seconds against 1.961 seconds for the standard build, so the single-thread overhead for this workload is about 13 percent, not 2. Even so, the free-threaded build at 8 workers (0.773 seconds) finished about 2.5 times faster than the best the standard build could do (1.961 seconds, with a single worker). The standard build again got slower as workers were added.
I did not profile why this workload scales less well, so I will not claim a cause. The HOWTO lists known costs of the build (for example “The free-threaded build will typically use more memory compared to the default build.”), and the lesson for you is practical: measure your own workload, and try fewer workers than your processor count, because the best number may be lower.
3.4 The rule of thumb
Free threading pays off when threads run pure Python code for long stretches. It adds nothing when threads mostly wait, or when the heavy lifting is already inside C code that releases the GIL, and it costs a few percent when only one thread is busy. The HOWTO’s own summary is measured: “While not all software will benefit from this automatically, programs designed with threading in mind will run faster on multi-core hardware.”
Step 4: Meet the bug the GIL was hiding
Faster threads are the good news. The bad news is that code which was quietly wrong can now fail loudly. In this step you will write the most ordinary counter in the world and watch it break.
4.1 Write a counter that has always worked
Save this as race_counter.py. Eight threads each add one to a shared counter 250,000 times, so the correct answer is 2,000,000. The script runs the experiment five times:
# race_counter.py
import threading
from benchkit import describe
THREADS = 8
INCREMENTS = 250_000
counter = 0
def bump_many():
global counter
for _ in range(INCREMENTS):
counter += 1
def one_run():
global counter
counter = 0
workers = [threading.Thread(target=bump_many) for _ in range(THREADS)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
return counter
print(describe())
expected = THREADS * INCREMENTS
for run in range(1, 6):
got = one_run()
print(f"run {run}: expected {expected:,} got {got:,} lost {expected - got:,}")
python3.15 race_counter.py
Python 3.15.1 standard build, GIL enabled: True
run 1: expected 2,000,000 got 2,000,000 lost 0
run 2: expected 2,000,000 got 2,000,000 lost 0
run 3: expected 2,000,000 got 2,000,000 lost 0
run 4: expected 2,000,000 got 2,000,000 lost 0
run 5: expected 2,000,000 got 2,000,000 lost 0
Five runs, five perfect answers. This is the code that passes every test you have ever run on the standard build. Now run the same file on the free-threaded build, and then on the same build with the GIL forced on:
python3.15t race_counter.py
python3.15t -X gil=1 race_counter.py
Free-threaded build:
Python 3.15.1 free-threading build, GIL enabled: False
run 1: expected 2,000,000 got 353,379 lost 1,646,621
run 2: expected 2,000,000 got 334,362 lost 1,665,638
run 3: expected 2,000,000 got 328,550 lost 1,671,450
run 4: expected 2,000,000 got 372,363 lost 1,627,637
run 5: expected 2,000,000 got 333,866 lost 1,666,134
Free-threaded build with the GIL forced on:
Python 3.15.1 free-threading build, GIL enabled: True
run 1: expected 2,000,000 got 2,000,000 lost 0
run 2: expected 2,000,000 got 2,000,000 lost 0
run 3: expected 2,000,000 got 2,000,000 lost 0
run 4: expected 2,000,000 got 2,000,000 lost 0
run 5: expected 2,000,000 got 2,000,000 lost 0
On the free-threaded build the counter ended between 328,550 and 372,363 instead of 2,000,000. Between 81 and 84 percent of the increments vanished, and the damage varied from run to run. With the GIL forced back on, the same program is perfect again. The file did not change. Only the lock did.
4.2 Why one += can lose an update
The line counter += 1 looks like one step, but it is several. The standard library’s dis module (see the dis documentation) shows the bytecode. Save this as bytecode.py:
# bytecode.py
import dis
counter = 0
def bump():
global counter
counter += 1
dis.dis(bump)
python3.15 bytecode.py
7 RESUME 0
9 LOAD_GLOBAL 0 (counter)
LOAD_SMALL_INT 1
BINARY_OP 13 (+=)
STORE_GLOBAL 0 (counter)
LOAD_COMMON_CONSTANT 7 (None)
RETURN_VALUE
Skip the housekeeping lines at the top and bottom. The middle four instructions are the whole update: LOAD_GLOBAL reads the current value, LOAD_SMALL_INT loads the number 1, BINARY_OP adds them, and STORE_GLOBAL writes the result back. That is a read-modify-write. If two threads both execute the first instruction before either executes the last, both read the same old value, both write the same new value, and one increment is lost. On the free-threaded build threads really do run at the same moment, so this happens constantly.
Why did the standard build never show it? The Python FAQ explains how the GIL decides when threads take turns: “In general, Python offers to switch among threads only between bytecode instructions; how frequently it switches can be set via sys.setswitchinterval().” So a switch can land between those four instructions, it just rarely does. In my runs it never did: the standard build lost nothing in five runs (10 million increments), and neither did the free-threaded build with the GIL forced on. That is luck, not a guarantee. The same FAQ lists i = i + 1 and D[x] = D[x] + 1 among the operations that “aren’t” atomic (an atomic operation is one that other threads see as a single indivisible step), and it ends with the advice “When in doubt, use a mutex!” A mutex is a lock. The newer Thread Safety Guarantees page, which documents the free-threaded build, says the same thing in its own words: “Operations that involve multiple accesses, as well as iteration, are never atomic.”
4.3 Two more races, and one thing that is safe
Counters are not the only pattern at risk. Save this as race_suite.py. It runs three experiments five times each: tallying events in a shared dictionary, filling a shared cache only when a key is missing (a check-then-act pattern), and appending to a shared list:
# race_suite.py
import threading
from benchkit import describe
THREADS = 8
ADDS = 100_000
TRIALS = 5
def run_threads(target):
workers = [threading.Thread(target=target) for _ in range(THREADS)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
def tally_trial():
"""Count events in a shared dict: read the old value, add one, write it back."""
tally = {}
def work():
for i in range(ADDS):
key = i % 10
tally[key] = tally.get(key, 0) + 1
run_threads(work)
return THREADS * ADDS - sum(tally.values()) # lost updates
def cache_trial():
"""Fill a shared cache only if the key is missing: check, then act."""
cache = {}
calls = []
start_line = threading.Barrier(THREADS)
def expensive():
calls.append(1)
total = 0
for i in range(60_000):
total += i
return total
def work():
start_line.wait()
if "config" not in cache:
cache["config"] = expensive()
run_threads(work)
return len(calls) # should be exactly 1
def append_trial():
"""list.append is documented as atomic, so nothing may go missing."""
items = []
def work():
for i in range(ADDS):
items.append(i)
run_threads(work)
return THREADS * ADDS - len(items)
print(describe())
print("dict tally, lost updates per trial :", [tally_trial() for _ in range(TRIALS)])
print("cache fill, expensive() calls :", [cache_trial() for _ in range(TRIALS)])
print("list.append, missing items :", [append_trial() for _ in range(TRIALS)])
python3.15 race_suite.py
python3.15t race_suite.py
Standard build:
Python 3.15.1 standard build, GIL enabled: True
dict tally, lost updates per trial : [0, 0, 0, 0, 0]
cache fill, expensive() calls : [1, 1, 1, 1, 1]
list.append, missing items : [0, 0, 0, 0, 0]
Free-threaded build:
Python 3.15.1 free-threading build, GIL enabled: False
dict tally, lost updates per trial : [225510, 225553, 231684, 233812, 228663]
cache fill, expensive() calls : [8, 7, 7, 7, 7]
list.append, missing items : [0, 0, 0, 0, 0]
On the free-threaded build the dictionary tally lost between 225,510 and 233,812 of the 800,000 updates (28 to 29 percent) in every trial. The cache-fill experiment should call expensive() exactly once; it called it 7 or 8 times, because all eight threads checked the cache, found it empty, and computed the value themselves. The standard build produced perfect results in all five trials of both experiments, which is exactly how these bugs survive for years. Step 4.4 shows how thin that protection is.
The third experiment is the reassuring one. list.append lost nothing on either build. The Thread Safety Guarantees page backs this up: “The following methods that only operate on a single element with no shifting required are atomic” and the list that follows includes lst.append(x). For dictionaries the page says “Writing or removing a single item is safe to call from multiple threads and will not corrupt the dictionary.” So a single built-in operation is safe on its own, while a sequence of them (read, then write) is not.
The functools documentation makes a related point about its caching decorators: “It is possible for the wrapped function to be called more than once if another thread makes an additional call before the initial call has been completed and cached. Call-once behavior is not guaranteed because locks are not held during the function call.” If you need a genuine call-once guarantee for expensive loads, see the single-flight pattern in How to Stop a Cache Stampede in Python.
4.4 Prove it: make the standard build switch threads more often
You do not have to take the FAQ’s word for it. The sys documentation explains that sys.setswitchinterval() sets the ideal duration of the timeslices given to concurrently running Python threads, and sys.getswitchinterval() reports it. On all three interpreters I used it reports 0.005 seconds. Save this as switch_demo.py. It repeats the counter and the dictionary tally on the standard build, first with the default interval and then with an interval of one microsecond:
# switch_demo.py
import sys
import threading
from benchkit import describe
THREADS = 8
ADDS = 250_000
counter = 0
tally = {}
def count():
global counter
for _ in range(ADDS):
counter += 1
def tally_up():
for i in range(ADDS):
key = i % 10
tally[key] = tally.get(key, 0) + 1
def run(target):
workers = [threading.Thread(target=target) for _ in range(THREADS)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
def one_trial():
global counter
counter = 0
tally.clear()
run(count)
run(tally_up)
expected = THREADS * ADDS
return expected - counter, expected - sum(tally.values())
print(describe())
for interval in (0.005, 0.000001):
sys.setswitchinterval(interval)
print(f"switch interval {interval} s, lost (counter, tally):", [one_trial() for _ in range(3)])
python3.15 switch_demo.py
Python 3.15.1 standard build, GIL enabled: True
switch interval 0.005 s, lost (counter, tally): [(0, 25000), (0, 0), (0, 0)]
switch interval 1e-06 s, lost (counter, tally): [(0, 633108), (0, 637986), (0, 622606)]
Each pair is the number of lost updates for the counter and for the tally in one trial. At the default interval the tally lost 25,000 updates in the first trial and nothing in the other two, so the bug is real but rare. With a one-microsecond interval it lost 633,108, 637,986 and 622,606 of the 2,000,000 updates, between 31 and 32 percent, in every trial. The counter stayed exact in all six trials, even then. Why the counter survived is an interpreter detail I did not investigate, and that is the point: you cannot work out from the outside which lines of your code are safe. The FAQ says as much: “In theory, this means an exact accounting requires an exact understanding of the PVM bytecode implementation.” (The PVM is the Python virtual machine.) The GIL never made your read-modify-write sequences safe. It only made them fail less often.
Step 5: Fix the races
There are two basic ways to make shared state safe. You can make the threads take turns with a lock, or you can stop sharing: let every thread work on its own data and combine the results once at the end. Save this as fix_counter.py; it measures both fixes next to the broken version:
# fix_counter.py
import threading
import time
from benchkit import describe
THREADS = 8
INCREMENTS = 250_000
EXPECTED = THREADS * INCREMENTS
lock = threading.Lock()
counter = 0
def unsafe(n):
global counter
for _ in range(n):
counter += 1
def locked(n):
global counter
for _ in range(n):
with lock:
counter += 1
def local_then_merge(n):
global counter
mine = 0
for _ in range(n):
mine += 1
with lock:
counter += mine
def measure(name, target, threads):
global counter
counter = 0
each = EXPECTED // threads
workers = [threading.Thread(target=target, args=(each,)) for _ in range(threads)]
start = time.perf_counter()
for worker in workers:
worker.start()
for worker in workers:
worker.join()
took = time.perf_counter() - start
verdict = "ok" if counter == EXPECTED else f"WRONG, lost {EXPECTED - counter:,}"
print(f"{name:<24}{counter:>10,} {took:>6.3f}s {verdict}")
print(describe())
print("variant total seconds verdict")
measure("1 thread, no lock", unsafe, 1)
measure("8 threads, no lock", unsafe, THREADS)
measure("8 threads, lock each add", locked, THREADS)
measure("8 threads, merge at end", local_then_merge, THREADS)
python3.15 fix_counter.py
python3.15t fix_counter.py
Standard build:
Python 3.15.1 standard build, GIL enabled: True
variant total seconds verdict
1 thread, no lock 2,000,000 0.035s ok
8 threads, no lock 2,000,000 0.037s ok
8 threads, lock each add 2,000,000 0.089s ok
8 threads, merge at end 2,000,000 0.024s ok
Free-threaded build:
Python 3.15.1 free-threading build, GIL enabled: False
variant total seconds verdict
1 thread, no lock 2,000,000 0.051s ok
8 threads, no lock 345,520 0.688s WRONG, lost 1,654,480
8 threads, lock each add 2,000,000 2.162s ok
8 threads, merge at end 2,000,000 0.007s ok
The with lock: statement acquires the lock, runs the indented code, and releases the lock even if an exception is raised. Only one thread at a time can be inside the block, so the read-modify-write can no longer interleave. The third variant, local_then_merge, never shares the counter while it works: each thread counts in its own local variable and takes the lock once, at the very end, to add its total.
Look at the free-threaded rows. Without a lock the total is wrong (345,520 instead of 2,000,000), and the run is also slow: 0.688 seconds, about 13 times the single thread’s 0.051 seconds. Eight threads hammering one shared variable are expensive even before you add a lock. The lock fixes the answer but costs a lot: 2.162 seconds, about 42 times slower than one thread doing all the work alone (0.051 seconds). Eight threads queuing for one lock are not running in parallel; they are a very expensive single thread. Merging at the end is correct and finished in 0.007 seconds, about seven times faster than the single thread. On the standard build all variants are correct, which is the GIL hiding the bug again.
5.1 Which fix should you choose?
- Combine at the end whenever you are counting, summing or collecting. Give each thread its own accumulator and merge the accumulators after the threads finish. This is the fastest option and needs a lock only once.
- Use a lock when threads must share state that changes while they run, and keep the locked section as short as possible. Avoid input, output and calls into unknown code while holding a lock.
- Hand work over with a queue. The queue documentation says “The queue module implements multi-producer, multi-consumer queues. It is especially useful in threaded programming when information must be exchanged safely between multiple threads.”
- Share immutable data where you can, because data that never changes cannot race. The post on Python 3.15’s frozendict shows how to freeze configuration and cache arguments.
The HOWTO gives the general rule, in a note about the dictionaries and lists you have been using: “It’s recommended to use the threading.Lock or other synchronization primitives instead of relying on the internal locks of built-in types, when possible.”
Common mistakes with locks: creating a new Lock() inside the function that uses it (each call gets its own lock, so nothing is protected), taking the lock for reads that need a consistent view and then forgetting it for writes, and acquiring two locks in different orders in different places, which can deadlock (each thread waits forever for the lock the other holds).
Step 6: Mind the behavior changes that come with the build
The free-threaded build changes a few defaults beyond removing the lock. The most important one for application code concerns context variables, which web frameworks and logging libraries use to carry a request ID or a user ID through a call chain without passing it as an argument. Save this as ctx_demo.py:
# ctx_demo.py
import contextvars
import threading
from benchkit import describe
request_id = contextvars.ContextVar("request_id", default="unset")
request_id.set("req-42")
def what_does_a_new_thread_see(**options):
seen = []
worker = threading.Thread(target=lambda: seen.append(request_id.get()), **options)
worker.start()
worker.join()
return seen[0]
print(describe())
print("plain Thread(...) sees:", what_does_a_new_thread_see())
print("Thread(context=copy_context()) sees:", what_does_a_new_thread_see(context=contextvars.copy_context()))
print("Thread(context=Context()) sees:", what_does_a_new_thread_see(context=contextvars.Context()))
python3.15 ctx_demo.py
python3.15t ctx_demo.py
Standard build:
Python 3.15.1 standard build, GIL enabled: True
plain Thread(...) sees: unset
Thread(context=copy_context()) sees: req-42
Thread(context=Context()) sees: unset
Free-threaded build:
Python 3.15.1 free-threading build, GIL enabled: False
plain Thread(...) sees: req-42
Thread(context=copy_context()) sees: req-42
Thread(context=Context()) sees: unset
The same code gives a different answer. On the standard build a new thread starts with an empty context, so it does not see req-42. On the free-threaded build it does. The HOWTO explains: “In the free-threaded build, the flag thread_inherit_context is set to true by default which causes threads created with threading.Thread to start with a copy of the Context() of the caller of start(). In the default GIL-enabled build, the flag defaults to false so threads start with an empty Context().” The Python 3.14 notes add that the flag “also affects other modules that use context variables, such as the decimal context manager.” A request ID that quietly reaches worker threads in one build and not in the other is exactly the kind of difference that makes a bug appear only in production.
The default of that flag follows the build, not the GIL: the forced-GIL run in Step 1.1 still printed True for threads inherit ctx. You can override it from the command line, and two more runs of the same script show the effect:
python3.15 -X thread_inherit_context=1 ctx_demo.py
python3.15t -X thread_inherit_context=0 ctx_demo.py
Standard build with the flag turned on:
Python 3.15.1 standard build, GIL enabled: True
plain Thread(...) sees: req-42
Thread(context=copy_context()) sees: req-42
Thread(context=Context()) sees: unset
Free-threaded build with the flag turned off:
Python 3.15.1 free-threading build, GIL enabled: False
plain Thread(...) sees: unset
Thread(context=copy_context()) sees: req-42
Thread(context=Context()) sees: unset
The best fix is to stop depending on a default and state what you want. The threading documentation says: “To explicitly start with an empty context, pass a new instance of Context(). To explicitly start with a copy of the current context, pass the value from copy_context().” The last two lines of the script do exactly that, and they print the same result on both builds. The context parameter was added in Python 3.14 (“Changed in version 3.14: Added the context parameter.”), so this works on Python 3.14 and later, which is where the free-threaded build became officially supported.
The HOWTO lists a related default for warnings. In the free-threaded build the flag context_aware_warnings is on: “If the flag is true then the warnings.catch_warnings context manager uses a context variable for warning filters. If the flag is false then catch_warnings modifies the global filters list, which is not thread-safe.”
One more difference to budget for is memory. The HOWTO says “The free-threaded build will typically use more memory compared to the default build,” and PEP 779 put a number on it when it was written: “free-threaded Python currently sees about 15-20% higher memory use (geometric mean, as measured by pyperformance).” I did not measure memory in this tutorial, so check your own service before you resize containers.
Step 7: Check that your dependencies keep the GIL off
Your code is only part of the picture. Many popular packages contain compiled extension modules, which are libraries written in C, C++ or Rust that Python loads like ordinary modules. An extension has to declare that it is safe to run without the GIL. The HOWTO describes what happens when one does not: “The GIL may also automatically be enabled when importing a C-API extension module that is not explicitly marked as supporting free threading. A warning will be printed in this case.” It also warns that “Some third-party packages, in particular ones with an extension module, may not be ready for use in a free-threaded build, and will re-enable the GIL.”
That makes this a silent failure mode: your free-threaded interpreter quietly turns into a standard one for the whole process the moment one bad import runs. The HOWTO points to two websites that track support, py-free-threading.github.io/tracking and hugovk.github.io/free-threaded-wheels. They are the right place to look first. It is also worth testing the packages you actually use, and the script below does it for any list of packages.
7.1 The survey script
Save this as survey.py. For each interpreter you name, it creates a fresh virtual environment with uv venv --python. Then, for each package, it runs uv pip install --only-binary :all:, which means install a prebuilt wheel or fail; without it, a missing wheel would send uv off to compile the package from source, which needs a compiler toolchain and can take a long time. If the install succeeds, the script imports the package in a brand-new process with warnings turned on and prints whether sys._is_gil_enabled() is still false:
# survey.py
"""survey.py 3.15t [3.14t ...]: install only binary wheels into a fresh venv, then import each package."""
import shutil
import subprocess
import sys
from pathlib import Path
PACKAGES = { # pip name: import name
"numpy": "numpy", "pandas": "pandas", "pydantic": "pydantic", "orjson": "orjson",
"lxml": "lxml", "pillow": "PIL", "cryptography": "cryptography", "psutil": "psutil",
"pyyaml": "yaml", "msgpack": "msgpack", "regex": "regex", "zstandard": "zstandard",
"bcrypt": "bcrypt", "argon2-cffi": "argon2", "markupsafe": "markupsafe",
"multidict": "multidict", "yarl": "yarl", "rpds-py": "rpds", "xxhash": "xxhash", "cffi": "cffi",
}
CHECK = """
import importlib.metadata as md, sys, warnings
warnings.simplefilter("always")
import {mod}
print(md.version("{pkg}"), sys._is_gil_enabled())
"""
def run(cmd):
return subprocess.run(cmd, capture_output=True, text=True)
for request in sys.argv[1:]:
venv = Path(f".survey-{request}")
shutil.rmtree(venv, ignore_errors=True)
run(["uv", "venv", "--python", request, str(venv)])
exe = ("Scripts", "python.exe") if sys.platform == "win32" else ("bin", "python")
python = str((venv / exe[0] / exe[1]).resolve())
banner = run([python, "-VV"]).stdout.strip()
print(f"== uv venv --python {request}: {banner}")
print(f"{'package':<14}{'version':<10}result")
wheels = off = 0
for pkg, mod in PACKAGES.items():
got = run(["uv", "pip", "install", "--python", python, "--only-binary", ":all:", pkg])
if got.returncode:
reason = [line for line in got.stderr.splitlines() if line.strip()][-1].strip()
print(f"{pkg:<14}{'-':<10}no binary wheel installed [{reason[:90]}]", file=sys.stderr)
print(f"{pkg:<14}{'-':<10}no binary wheel for this interpreter")
continue
wheels += 1
checked = run([python, "-c", CHECK.format(mod=mod, pkg=pkg)])
if checked.returncode:
print(f"{pkg:<14}{'?':<10}import failed: {checked.stderr.strip().splitlines()[-1]}")
continue
version, gil = checked.stdout.split()
if gil == "False":
off += 1
print(f"{pkg:<14}{version:<10}installs, GIL stays off")
else:
print(f"{pkg:<14}{version:<10}installs, but importing it turned the GIL on")
print(f"-> {wheels} of {len(PACKAGES)} installed as binary wheels, {off} kept the GIL off\n")
python3.15 survey.py 3.15t 3.14t
Here is what I got on 22 October 2026, on Windows x64. First the Python 3.15 free-threaded build:
== uv venv --python 3.15t: Python 3.15.1 free-threading build (main, Oct 21 2026, 17:52:45) [MSC v.1951 64 bit (AMD64)]
package version result
numpy 2.5.4 installs, GIL stays off
pandas 3.1.0 installs, GIL stays off
pydantic 2.14.1 installs, GIL stays off
orjson - no binary wheel for this interpreter
lxml 6.1.3 installs, GIL stays off
pillow 13.0.0 installs, GIL stays off
cryptography 50.0.2 installs, GIL stays off
psutil - no binary wheel for this interpreter
pyyaml - no binary wheel for this interpreter
msgpack 1.2.3 installs, GIL stays off
regex 2026.9.29 installs, GIL stays off
zstandard - no binary wheel for this interpreter
bcrypt - no binary wheel for this interpreter
argon2-cffi 25.1.0 installs, GIL stays off
markupsafe 3.0.4 installs, GIL stays off
multidict 7.1.0 installs, GIL stays off
yarl 1.25.1 installs, GIL stays off
rpds-py 2026.9.1 installs, GIL stays off
xxhash 4.0.2 installs, GIL stays off
cffi 2.1.1 installs, GIL stays off
-> 15 of 20 installed as binary wheels, 15 kept the GIL off
And the Python 3.14 free-threaded build:
== uv venv --python 3.14t: Python 3.14.9 free-threading build (main, Oct 21 2026, 17:49:28) [MSC v.1944 64 bit (AMD64)]
package version result
numpy 2.5.4 installs, GIL stays off
pandas 3.1.0 installs, GIL stays off
pydantic 2.14.1 installs, GIL stays off
orjson - no binary wheel for this interpreter
lxml 6.1.3 installs, GIL stays off
pillow 13.0.0 installs, GIL stays off
cryptography 50.0.2 installs, GIL stays off
psutil 7.2.2 installs, GIL stays off
pyyaml 6.0.3 installs, GIL stays off
msgpack 1.2.3 installs, GIL stays off
regex 2026.9.29 installs, GIL stays off
zstandard - no binary wheel for this interpreter
bcrypt 5.0.0 installs, GIL stays off
argon2-cffi 25.1.0 installs, GIL stays off
markupsafe 3.0.4 installs, GIL stays off
multidict 7.1.0 installs, GIL stays off
yarl 1.25.1 installs, GIL stays off
rpds-py 2026.9.1 installs, GIL stays off
xxhash 4.0.2 installs, GIL stays off
cffi 2.1.1 installs, GIL stays off
-> 18 of 20 installed as binary wheels, 18 kept the GIL off
7.2 What the survey tells you
Fifteen of the twenty packages installed as prebuilt wheels for 3.15t, and every one of them kept the GIL off after import. Five had no wheel for this interpreter on this platform: orjson, psutil, pyyaml, zstandard and bcrypt. For 3.14t the count was eighteen of twenty, with only orjson and zstandard missing. So three packages that install on 3.14t had no matching wheel for 3.15t on Windows that day, a reminder that a brand-new Python version trails the ecosystem. I did not find any package in this list that turns the GIL back on, so Step 8 simulates one. These results change from week to week and differ by operating system, so rerun the script for your own list.
When a package you need has no wheel, you have honest options: stay one Python version back (3.14t covered more of this list), ask the maintainers or watch the trackers, keep that component on the standard build behind a process boundary, or accept the standard build for that application. What you should not do is hope. Test.
Step 8: Keep it from regressing: test on both builds
Everything above is a one-time investigation. To keep the result true, your tests have to run on the free-threaded build too, and they must be able to fail there.
8.1 A test that fails only without the GIL
Save these two files. hitcounter.py contains the broken counter and the locked counter. test_hitcounter.py hammers each one from eight threads and checks that no hit was lost:
# hitcounter.py
import threading
class UnsafeHitCounter:
def __init__(self):
self.hits = 0
def hit(self):
self.hits += 1
class LockedHitCounter:
def __init__(self):
self.hits = 0
self._lock = threading.Lock()
def hit(self):
with self._lock:
self.hits += 1
# test_hitcounter.py
import threading
import pytest
from hitcounter import LockedHitCounter, UnsafeHitCounter
THREADS = 8
HITS_PER_THREAD = 100_000
def hammer(counter):
def work():
for _ in range(HITS_PER_THREAD):
counter.hit()
workers = [threading.Thread(target=work) for _ in range(THREADS)]
for worker in workers:
worker.start()
for worker in workers:
worker.join()
@pytest.mark.parametrize("make", [UnsafeHitCounter, LockedHitCounter], ids=["unsafe", "locked"])
def test_no_hit_is_lost(make):
counter = make()
hammer(counter)
assert counter.hits == THREADS * HITS_PER_THREAD
The parametrize line runs the same test twice, once per class, and the ids give the two runs the names unsafe and locked. Each run starts eight threads that call hit() 100,000 times each, then checks that all 800,000 hits were counted.
8.2 A guard for the GIL itself
Save this as test_gil_guard.py. On a free-threaded build it checks that the GIL is off before any import, then imports your compiled dependencies one by one and checks again after each. On a standard build the test is skipped, because there is nothing to protect:
# test_gil_guard.py
import importlib
import sys
import sysconfig
import pytest
# Everything your application imports that ships a compiled extension module.
DEPENDENCIES = ["numpy", "pydantic", "lxml"]
@pytest.mark.skipif(not sysconfig.get_config_var("Py_GIL_DISABLED"), reason="not a free-threaded build")
def test_imports_keep_the_gil_off():
assert not sys._is_gil_enabled(), "the GIL was already on before any import"
for name in DEPENDENCIES:
importlib.import_module(name)
assert not sys._is_gil_enabled(), f"importing {name} turned the GIL back on"
The list at the top should contain every package you import that ships a compiled extension. I used three from the survey. Now run the tests on all three setups. The uv run command below picks the interpreter with --python, adds packages for this one run with --with (repeat it for each package, as the uv scripts guide explains), and uses --no-project so that uv does not install a project from the current folder. The -q after uv run hides uv’s own install messages:
uv run -q --no-project --python 3.15 --with pytest --with numpy --with pydantic --with lxml python -m pytest -q --tb=no -rf
uv run -q --no-project --python 3.15t --with pytest --with numpy --with pydantic --with lxml python -m pytest -q --tb=no -rf
uv run -q --no-project --python 3.15t --with pytest --with numpy --with pydantic --with lxml python -X gil=1 -m pytest -q --tb=no -rf
The three commands run the same two test files. In pytest’s output a dot is a pass, F is a failure and s is a skip, and the files run in alphabetical order, so the guard comes first. Standard build, where the guard is skipped because there is no free threading to protect:
s.. [100%]
2 passed, 1 skipped in 0.09s
Here is the trap. The broken counter passes on the standard build, so a project that tests only there would ship it. Free-threaded build:
.F. [100%]
=========================== short test summary info ===========================
FAILED test_hitcounter.py::test_no_hit_is_lost[unsafe] - assert 174685 == (8 ...
1 failed, 2 passed in 1.60s
Now the broken counter fails, with a count far below 800,000 that differs from run to run, while the guard and the locked counter pass. Finally, the free-threaded build with the GIL forced on from the start:
F.. [100%]
=========================== short test summary info ===========================
FAILED test_gil_guard.py::test_imports_keep_the_gil_off - AssertionError: the...
1 failed, 2 passed in 0.13s
I could not find a package among the twenty in Step 7 that turns the GIL back on, so this third run simulates one by forcing the GIL on before anything is imported. The guard fails with its own message, which is what you want to see when a real dependency does it. With a real offender, the failure would come from the loop, on the import that caused it, and Python would print the warning that the HOWTO describes. To make the suite pass on both builds, use LockedHitCounter in your real code.
8.3 Add the free-threaded build to your CI
Run the same suite on both builds in continuous integration, so that a new race condition, or a new dependency that re-enables the GIL, turns a build red before it reaches users. The setup-python action documents a free-threaded example in its README, with python-version: '3.13t', and the matrix below applies the same suffix to 3.15. The versions manifest the action downloads from, in the actions/python-versions repository, lists free-threaded 3.15.1 builds for Linux, macOS and Windows. Save this as .github/workflows/tests.yml:
# ci.yml
name: tests
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
python-version: ["3.15", "3.15t"]
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: ${{ matrix.python-version }}
- run: python -m pip install pytest numpy pydantic lxml
- run: python -m pytest -q --tb=short
I did not run this workflow. Its shape follows the README example and the manifest, and the only runs I can vouch for are the local ones above. Treat the first run in your own repository as the test.
Confirm everything works end to end
Run through this checklist. If every line matches in shape, you have a working free-threaded setup and a lab you can reuse for your own code.
python3.15t whoami.pyprintsGIL enabled now : False, and the same command with-X gil=1printsTrue.python3.15t cpu_bench.pyshows a speedup well above 1 at the largest worker count (9.74x here), whilepython3.15 cpu_bench.pystays at or below 1.io_bench.pyandhash_bench.pylook about the same on both builds.race_counter.pyloses updates onpython3.15tand none onpython3.15.fix_counter.pyprintsokfor the lock and for the merge-at-end variant on both builds, andWRONGfor the unlocked variant on the free-threaded build only.survey.pyprintsGIL stays offfor every package that installs, or tells you plainly which ones do not.- The pytest runs give the three outcomes above: the unsafe counter passes on the standard build and fails on the free-threaded one, and the guard fails when the GIL is forced on.
The table below collects the main numbers from this tutorial, all measured on the machine described at the top:
| Check | Standard 3.15.1 | Free-threaded 3.15.1 |
|---|---|---|
| CPU-bound job, speedup at 14 workers | 0.75x | 9.74x |
| Waiting job, 28 tasks on 14 workers | 0.21 s | 0.21 s |
| Hashing job, speedup at 14 workers | 11.37x | 10.79x |
| Allocation-heavy job, best time | 1.961 s (1 worker) | 0.773 s (8 workers) |
| Shared counter, 2,000,000 increments | exact in 5 of 5 runs | 81 to 84 percent lost |
| Shared dictionary tally, 800,000 updates | nothing lost in 5 of 5 trials | 28 to 29 percent lost |
| Counter with a lock, 8 threads | 0.089 s, exact | 2.162 s, exact |
| Counter merged at the end, 8 threads | 0.024 s, exact | 0.007 s, exact |
| New thread sees the parent’s context variable | no | yes |
Common mistakes and how to recover
The free-threaded build is not faster
Run whoami.py first. If it prints GIL enabled now : True, then PYTHON_GIL or -X gil is set, or an import turned the GIL back on (the HOWTO says a warning is printed in that case, so read your terminal output from the top). If the GIL is off and there is still no speedup, your threads are probably waiting instead of computing, or the heavy work is inside C code that already releases the lock, as in Steps 3.1 and 3.2.
The numbers change from run to run
Some variation is normal. Close other heavy programs, keep the best of several runs as the harness does, and compare ratios rather than seconds. Differences between processors and operating systems can be large.
A test passes on one build and fails on the other
Do not rerun it until it passes. That is the race condition showing itself. Find the read-modify-write or check-then-act sequence and fix it with a lock, a queue or per-thread data, as in Step 5.
Sharing one iterator between threads
The HOWTO warns: “It is generally not thread-safe to access the same iterator object from multiple threads concurrently, and threads may see duplicate or missing elements.” I tried a shared range iterator with eight threads and could not make it misbehave in three runs of 200,000 items on the standard build, the free-threaded build or the free-threaded build with the GIL on, which proves nothing. Give each thread its own iterator, or hand out items through a queue.Queue.
Where to go next
- Find where your threads spend their time. Python 3.15’s sampling profiler has a mode for exactly this question. The What’s New in Python 3.15 page says its GIL mode “Measures time spent holding Python’s Global Interpreter Lock. Use this to identify which threads dominate GIL usage in multi-threaded applications.” Our Tachyon sampling profiler tutorial shows how to use the profiler.
- Use asyncio for waiting-heavy work. Step 3.1 showed that waiting already overlaps. How to Replace asyncio.gather With TaskGroup covers the structured way to run many waiting tasks.
- Test against 3.15 early. Our earlier article Python 3.15’s Feature Freeze Is the Signal to Start Testing explains why.
- Maintain a package with a C extension? The 3.15 notes say “C extensions that target the Stable ABI can now be compiled for the new Stable ABI for Free-Threaded Builds (also known as abi3t), which makes them compatible with free-threaded builds of CPython.” The community-maintained Python Free-Threading Guide covers porting and testing.
Sources
- Python support for free threading, Python 3.15.1 documentation
- Thread Safety Guarantees, Python 3.15.1 documentation
- Library and Extension FAQ, section on thread-safe global value mutation
- What’s New In Python 3.14 and What’s New In Python 3.15
- PEP 779 and PEP 703
- Python documentation: glossary, hashlib, threading, functools, sys, os, queue, dis
- uv documentation: Python versions and installation
- actions/setup-python and actions/python-versions
- Free-threading support trackers: py-free-threading.github.io/tracking and hugovk.github.io/free-threaded-wheels








No Comment! Be the first one.