TRENDING
Unpopped corn kernels mixed with popped popcorn on a white surface
October 28, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
Ouroboros, a polished stainless steel sculpture of two interlocking loops with a pierced surface, standing beside a dark pool under trees
October 28, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
Two opened leaves of the Verona palimpsest, with an older, darker layer of writing still showing through the newer script written over it
October 28, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July
A raft of long grey wooden logs lying in a green meadow, with a forked post standing in the middle
October 28, 2026
How to Build the Raft Consensus Algorithm in Python and Test It With Crashes, Partitions, and Planted Bugs
Open handwritten account book from the Brussels Confraternity of the Seven Sorrows (1499 to 1516), with entries running down each page and blank space below them
October 28, 2026
Anthropic’s S-1 Puts $52.9 Billion More on Three Named Compute Commitments Than Its October 9 Draft Did, and Still Leaves Its Contractual-Obligations Table Blank
28 Oct 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Vintage silver stopwatch with a chain on a black background, its dial showing seconds from 5 to 60
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
A wooden Tower of Hanoi puzzle on a market table, with three pegs and stacked discs of different sizes
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Two earthworms lying together on wet brown leaf litter, photographed close up
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 268 Posts
News 271 Posts
Learning Hub 241 Posts
Home/Learning Hub/How to Use Free-Threaded Python 3.15 to Run Threads in Parallel and Fix the Race Conditions the GIL Was Hiding
Learning Hub

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.

October 22, 2026 36 Min Read
31

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:

  1. 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.
  2. 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.
  3. 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).
  4. 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.
  5. 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 what threading.Thread does. 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.py or hitcounter.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=1 also 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.

  1. python3.15t whoami.py prints GIL enabled now : False, and the same command with -X gil=1 prints True.
  2. python3.15t cpu_bench.py shows a speedup well above 1 at the largest worker count (9.74x here), while python3.15 cpu_bench.py stays at or below 1.
  3. io_bench.py and hash_bench.py look about the same on both builds.
  4. race_counter.py loses updates on python3.15t and none on python3.15.
  5. fix_counter.py prints ok for the lock and for the merge-at-end variant on both builds, and WRONG for the unlocked variant on the free-threaded build only.
  6. survey.py prints GIL stays off for every package that installs, or tells you plainly which ones do not.
  7. 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

Tags:

ConcurrencyFree-Threaded PythonPerformance EngineeringpytestPythonPython 3.15

Share

A black-tubed stethoscope with cream ear tips and a steel chest piece laid flat on a light gray surface
Previous Post

Microsoft’s MAI-DxO Turns “AI Beats Doctors” Into a Cost-Control Claim, Because Plain GPT-5 Already More Than Doubles the Open-Book Doctor Score

A cluster of glossy red baneberry fruits on a thin stem against a blurred green background
Next Post

A Poisoned LiteLLM 1.107.0 Sat on PyPI for 103 Minutes, Published by the Project’s Own Pipeline From a Repository One Hyphen Away From the Real One

No Comment! Be the first one.

Leave a Reply Cancel reply

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

Latest
27 Oct
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
27 Oct
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
Trending
October 27, 2026
How to Find a Slow Query in PostgreSQL 18 With pg_stat_statements, Read Its EXPLAIN ANALYZE Plan, and Fix It With the Right Index
October 27, 2026
Two Labs’ Claimed Refutations of the Exponential Time Hypothesis Turn Verification Into a Statement-Reading Problem, and Their Lean Theorems Promise Only That a Constant Exists
October 27, 2026
Tailscale Merged Fixes for Most of the 11 “Tailworm” Findings Before Shipping a Release, and the Forwarding Switch It Recommends Already Exists in the Shipped Client
October 27, 2026
How to Stop Decompression Bombs in Python With Hard Output Limits for gzip, Brotli, zstd, ZIP, and tar.gz
October 27, 2026
Anthropic’s S-1 Puts Safety Audits Inside the “Closing the Loop” Cycle It Warns About, and Says Its Evaluations Could Overstate Safety
October 27, 2026
Cloudflare Says Its GLM-5.3 Model Returned Other Customers’ Data in 155 Responses, and a Public SGLang Bug Report Had Described the Same Path Since July

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026