Free-Threaded Python (No-GIL): Writing Thread-Safe Code

Free-threaded Python (python3.14t) runs threads in parallel without the GIL. We install the build, reproduce race conditions and write thread-safe code with locks and queues.

Timeline of two threads on two cores. With the GIL the threads take turns and wait for each other, in the free-threaded build both threads run at the same time.

Free-threaded Python, also called no-GIL Python, refers to a build of CPython that runs without the Global Interpreter Lock. The GIL is a lock inside the interpreter that lets one thread run Python bytecode, the compiled instructions the interpreter executes, at a time. Take that lock away and threads run Python code on several CPU cores at once. Python 3.14 lists the build as supported in PEP 779, a Python Enhancement Proposal, but the build is optional and the standard build keeps the GIL.

We use this build for CPU-bound work, meaning work that keeps the processor busy instead of waiting on a disk or a network, such as image processing or number crunching. That kind of work gained nothing from threads before. So shared data that several threads change now needs a lock or a queue, because the GIL only ever made our race conditions rare and never made them impossible.

The example below prints which build is running and protects a shared counter with a lock.

import sys
import sysconfig
import threading

free_threaded_build = sysconfig.get_config_var("Py_GIL_DISABLED") == 1  # True on python3.14t, False on python3.14
gil_enabled = sys._is_gil_enabled()  # False on python3.14t, True on python3.14

counter = 0
lock = threading.Lock()


def increment():
    global counter
    with lock:  # without the lock, counter += 1 can lose updates
        counter += 1


threads = [threading.Thread(target=increment) for _ in range(4)]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(free_threaded_build, gil_enabled, counter)  # counter is 4 on both builds

Four increments are too few for a thread to lose one, so this script prints 4 whichever build runs it. Repeat the same increment a million times and the lock starts to matter, which is what section 3 runs. From here we install the build, watch two races happen, then work through the locks, queues and executors that stop them.

1. Free-Threaded Python and the GIL

A thread is a unit of execution inside a process, and all threads of one process share the same memory, so they see the same Python objects. In CPython, the reference implementation of Python, the GIL is a mutex inside the interpreter, and it allows one thread to run Python bytecode at a time. The lock is there to protect the interpreter’s own data, such as reference counts. As a side effect it also serializes many operations on the objects we write.

Threads still help with I/O-bound work, which is work that spends its time waiting for a disk, a database or a network, because a thread that waits releases the GIL and another thread runs. CPU-bound threads gain nothing from this. They take turns on one core, which is the left half of the diagram below.

Free threading is a separate build of the interpreter rather than a switch inside the normal one. PEP 703 added it as the build option –disable-gil, and the executable carries a t suffix, so python3.14t sits next to python3.14 on the same machine. Inside the build, the one global lock is replaced by smaller mechanisms, the most visible of which is a separate lock on each list, dict and set object.

Timeline of two threads on two cores. With the GIL the threads take turns and wait for each other, in the free-threaded build both threads run at the same time.
With the GIL, two threads take turns. In the free-threaded build, both threads run Python code at the same time.

1.1. Status of Free-Threaded Python in 2026

PEP 703 splits the rollout into three phases, and we’re in the middle one today.

  • In phase I the free-threaded build ships as experimental, which Python 3.13 did.
  • In phase II the build is supported but optional, which is where Python 3.14 and 3.15 sit.
  • In phase III the free-threaded build becomes the default, and no release has reached it.

Each release since 3.13 has moved one piece of this along, either the status itself or the tooling around it. The third column is the piece that moved in each one.

Python versionRelease dateFree-threaded build
3.132024-10-07Experimental (phase I of PEP 703)
3.142025-10-07Supported, optional (PEP 779, phase II)
3.152026-10-09Still optional. Adds the stable binary interface abi3t for free-threaded extensions (PEP 803) and installs free-threading support by default in the macOS binaries

Supported, in the wording PEP 779 uses, means the design is settled and the APIs won’t shift under us. It doesn’t mean the build is the one we get by default. The standard build with the GIL is still what python3.15 runs, and PEP 779 leaves phase III to a future PEP with no date on it.

2. Installing and Checking the Free-Threaded Build

Because phase II ships the build with every release, we don’t compile anything ourselves on the common platforms. The free-threaded interpreter is a separate executable, so it installs next to the standard one and leaves it alone. Three routes get it onto a machine.

  • The tool uv is a package and project manager for Python that also downloads interpreters, and uv python install 3.14t puts python3.14t on the path.
  • The python.org installers for macOS and Windows carry free-threaded binaries as an option we tick during the install, and from 3.15 the macOS installer selects free-threading support for us.
  • On every other platform we build CPython from source with ./configure –disable-gil, or follow the community installation guide.
uv python install 3.14t            # installs python3.14t next to python3.14
uv run --python 3.14t python check_build.py  # runs a script with the free-threaded build

Nothing downstream of the interpreter changes. We create a virtual environment with python3.14t -m venv .venv, install into it with pip, and a Docker image or a CI job names 3.14t the way it would name any other version. What does change is which wheels pip can find, and section 5 covers that.

Once it’s installed, three different questions come up about the build, and Python answers each one with its own check.

  • Which interpreter am I running? The output of python -VV contains the words “free-threading build” in the free-threaded build and doesn’t in the standard one.
  • Does this build support free threading at all? The call sysconfig.get_config_var(“Py_GIL_DISABLED”) returns 1 when it does, and the Python docs point to this one for build-time decisions.
  • Is the GIL off right now? The call sys._is_gil_enabled() answers that, and its answer can differ from the build, because the GIL can come back on at run time.

The script below prints the last two of those three. We ran it on Python 3.14.6, once with each build.

import sys
import sysconfig

print(sys.version)
print("Free-threaded build:", sysconfig.get_config_var("Py_GIL_DISABLED") == 1)
print("GIL enabled now:", sys._is_gil_enabled())
$ python3.14 check_build.py
3.14.6 (main, Jul 18 2026, 17:01:31) [Clang 22.1.3 ]
Free-threaded build: False
GIL enabled now: True

$ python3.14t check_build.py
3.14.6 free-threading build (main, Jul 18 2026, 17:01:48) [Clang 22.1.3 ]
Free-threaded build: True
GIL enabled now: False

Note that the last two lines can disagree. The free-threaded build runs with the GIL on when we ask it to, through the environment variable PYTHON_GIL or the option -X gil, both of which take 0 for off and 1 for on. Either setting is useful to compare the two modes with one interpreter, or to get past a package that breaks without the GIL.

PYTHON_GIL=1 python3.14t check_build.py   # Free-threaded build: True, GIL enabled now: True
python3.14t -X gil=1 check_build.py       # Free-threaded build: True, GIL enabled now: True

3. Race Conditions and Thread Safety

Those checks tell us which build is running. What changes between the two builds is how often a threading bug shows itself, and four terms describe the bug we’re chasing.

  • A race condition is a bug where the result depends on the timing of the threads.
  • A data race is the narrower case where several threads reach the same memory without synchronization and at least one of them writes.
  • Code is thread-safe when it gives the correct result for every order in which the threads could run.
  • An operation is atomic when no other thread can catch it half done.

Both examples in this section are race conditions, and both are data races too, because the threads read and write the same variable with nothing in between. Note that a data race in Python code corrupts our own values and leaves the interpreter intact. The per-object locks keep the interpreter’s own structures consistent, so we lose updates rather than crash.

Let’s say a ticket shop counts the tickets sold from several worker threads. The line counter += 1 looks like one step, and the interpreter runs three. It reads the value, adds one, then writes the result back, and another thread can slip in between any two of them, as the diagram shows.

Sequence of two threads that both read the counter value 5, both add 1 and both write 6, so the counter ends at 6 instead of 7.
Both threads read 5 before either one writes, so one increment of counter += 1 is lost.

The GIL doesn’t make counter += 1 atomic. It hands the interpreter from one thread to another only at certain points, so on the standard build the three steps usually finish before the hand-off and the bug shows up once in a very long while. Code like this passes its tests there and is still wrong. On the free-threaded build the two threads run at the same instant, so the same code loses updates on most runs.

3.1. Race Condition Demo With and Without a Lock

That claim is worth measuring. Four threads each add 1 to a shared counter 250,000 times, once with no lock at all and once inside a threading.Lock, so the correct total is 1,000,000 either way. We ran it on Python 3.14.6 on Linux, and the whole file sits in the example project.

import threading

THREADS = 4
LOOPS = 250_000

counter = 0
safe_counter = 0
lock = threading.Lock()


def unsafe_increment():
    global counter
    for _ in range(LOOPS):
        counter += 1


def safe_increment():
    global safe_counter
    for _ in range(LOOPS):
        with lock:
            safe_counter += 1
$ python3.14 race_counter.py
GIL enabled: True
Expected:         1000000
Without a lock:   1000000
With a lock:      1000000

$ python3.14t race_counter.py
GIL enabled: False
Expected:         1000000
Without a lock:   286358
With a lock:      1000000

We ran each build 16 times. The standard build returned the full 1,000,000 every time, lock or no lock. The free-threaded build lost updates on 9 of those 16 runs when the lock was gone, by between 25% and 70%, and lost nothing on any run with the lock in place. That spread is wide because the loss depends on how often the hand-off lands between the read and the write, which moves with whatever else the machine is doing.

3.2. Check-Then-Act Races

A lost increment is the easy case, because one lock around one line fixes it. A check-then-act race is the one that survives that fix. It happens when a thread tests a condition and acts on the answer, while another thread changes the condition in the gap. Eight buyer threads compete for 100 tickets below, each one reading tickets_left before it decrements the value, and time.sleep (0) widens the gap so the race shows on both builds.

import threading
import time

TICKETS = 100
BUYERS = 8

tickets_left = TICKETS
sold = 0
lock = threading.Lock()


def buy_unsafe():
    global tickets_left, sold
    for _ in range(TICKETS):
        if tickets_left > 0:  # check
            time.sleep (0)  # another thread can run here
            tickets_left -= 1  # act
            sold += 1


def buy_safe():
    global tickets_left, sold
    for _ in range(TICKETS):
        with lock:
            if tickets_left > 0:
                time.sleep (0)
                tickets_left -= 1
                sold += 1
$ python3.14 check_then_act.py
GIL enabled: True
Tickets available: 100
Without a lock, sold/left: (107, -7)
With a lock, sold/left:    (100, 0)

$ python3.14t check_then_act.py
GIL enabled: False
Tickets available: 100
Without a lock, sold/left: (106, -6)
With a lock, sold/left:    (100, 0)

Four steps produce the oversell, and two threads are enough to show them.

  • Thread A reads tickets_left as 1 and passes the check.
  • Thread A gives up the interpreter at time.sleep (0), before it has changed anything.
  • Thread B reads the same 1, passes the same check and sells the ticket, so tickets_left is 0.
  • Thread A carries on from where it stopped and sells the ticket it already decided on, so tickets_left is -1.

The lock has to cover the check and the act together, not each one on its own. Both builds oversold without it. The standard build printed the same two numbers in every run we made, because its hand-off points fall in the same places each time, whereas the free-threaded build sold between 103 and 107 tickets depending on the run.

4. Writing Thread-Safe Code

A lock closed both races above, and it is one of four tools the standard library gives us for this, alongside the built-in containers that already protect themselves. Which one fits depends on how the threads share data. Threads that share nothing need no protection, threads that hand work items to each other want a queue that does the locking for them, and threads that change the same object want a lock.

Decision tree that leads from the question whether threads change the same data to ThreadPoolExecutor, queue.Queue, no extra lock for a single built-in call, threading.RLock or threading.Lock.
We choose ThreadPoolExecutor, queue.Queue, threading.RLock or threading.Lock by how the threads share data.

The threading module carries four more classes that the tree leaves out, because they coordinate threads rather than guard data. An Event signals a one-off state change, such as “the config is loaded”. A Condition parks a thread until another thread announces that something changed. A Semaphore caps how many threads enter a section at once. And threading.local gives every thread its own copy of a value, which removes the sharing instead of guarding it.

4.1. Protecting Shared Data With threading.Lock

In Python a lock, also called a mutex, lets one thread at a time run the code it guards while the other threads wait for it to be released. The class is threading.Lock. The method acquire() takes the lock and release() hands it back, but we use the lock as a context manager instead, which is an object the with statement sets up on entry and cleans up on exit. That matters here because the cleanup runs even when the code inside raises.

import threading

tickets_left = 100
lock = threading.Lock()

with lock:  # acquire() on entry, release() on exit, even after an exception
    tickets_left -= 1  # tickets_left = 99

got_it = lock.acquire(timeout=2)  # True, or False after waiting 2 seconds
if got_it:
    lock.release()

Keep the body of the with block short, because every other thread is stopped for as long as it runs. That advice costs more on the free-threaded build than it used to. A lock held through most of a hot loop serializes the threads again and gives back the parallel speedup we switched builds for. When a piece of code needs two locks, every thread takes them in the same order, or two threads end up each holding what the other one wants, which is a deadlock.

4.2. Re-Entrant Locks With threading.RLock

A plain Lock keeps no record of which thread holds it. It knows only that it is taken, so a second acquire() blocks whoever calls it, including the thread that already owns the lock. That thread then waits for a release only it could perform, so it waits forever. The class threading.RLock, the re-entrant lock, counts instead. The owning thread acquires it again as often as it likes, and the lock opens to other threads once the owner has released it the same number of times.

A Playlist class shows where that comes up. The method add_many() takes the lock and then calls add(), which takes the same lock again, so a plain Lock would stop the first call to add_many() and never resume it.

import threading


class Playlist:
    def __init__(self):
        self._songs = []
        self._lock = threading.RLock()

    def add(self, song):
        with self._lock:
            if song not in self._songs:
                self._songs.append(song)

    def add_many(self, songs):
        with self._lock:  # holds the lock, then add() takes it again
            for song in songs:
                self.add(song)

Four threads call add_many() with overlapping ranges of songs, 0 to 4, 3 to 7, 6 to 10 and 9 to 13. Both builds finish with the same 14 songs and no duplicates, because add() does its check and its append under one lock.

4.3. Passing Work Between Threads With queue.Queue

Locks guard data that threads share. The producer-consumer pattern avoids the sharing instead, by splitting the work into threads that create items and threads that process them, with a queue in the middle. The class queue.Queue does its own locking, so we add none of our own. A producer calls put(), a consumer calls get() and waits there while the queue is empty, and task_done() paired with join() lets the producer wait until every item has been handled.

Four worker threads read orders off one queue below and put their results on a second one. A worker has no other way to learn that the work is over, so we push one None per worker at the end as a sentinel, which is a value that means “stop” rather than a real work item.

import queue
import threading

WORKERS = 4
orders = queue.Queue()
totals = queue.Queue()


def worker():
    while True:
        order = orders.get()
        if order is None:  # sentinel, tells this worker to stop
            orders.task_done()
            break
        totals.put((order, order * 2))
        orders.task_done()
[(1, 2), (2, 4), (3, 6), (4, 8), (5, 10), (6, 12), (7, 14), (8, 16)]

The workers finish in whatever order the scheduler gives them, so the printing code sorts the pairs before it prints. A queue guarantees that no result is lost or duplicated, and it promises nothing about the order they come back in.

4.4. Collecting Results With ThreadPoolExecutor

The worker loop and the sentinel are plumbing nobody wants to write twice, and concurrent.futures.ThreadPoolExecutor writes both for us. It runs a pool of worker threads and hands the results back. The method map() calls a function once per input and returns the results in input order, while submit() returns a Future whose result() waits for the value. Each task returns its own result, so the threads share nothing and no lock appears anywhere.

from concurrent.futures import ThreadPoolExecutor


def count_primes(limit):
    count = 0
    for n in range(2, limit):
        if all(n % d for d in range(2, int(n ** 0.5) + 1)):
            count += 1
    return count


limits = [10_000, 20_000, 30_000, 40_000]

with ThreadPoolExecutor(max_workers=4) as pool:
    results = list(pool.map(count_primes, limits))  # each task returns its own result

print(dict(zip(limits, results)))  # {10000: 1229, 20000: 2262, 30000: 3245, 40000: 4203}

On the free-threaded build these four tasks run on up to four cores at the same time, and on a two-core machine such as our test box two of them run while the other two wait. The standard build runs them one at a time whatever the machine has. Nothing in this example needs a lock, because no task reads what another task wrote.

4.5. Thread-Safe Built-in Types and Per-Object Locks

Tasks that share nothing are the comfortable case. The uncomfortable one is the module-level dict used as a cache, the class attribute that counts something, or the singleton built on first use, all of which several threads reach at once. In the free-threaded build, list, dict and set hold a lock per object, so one operation on one of them never leaves the container damaged. That lock covers one operation and stops there. Two operations in a row, a read and then a write, are two separate locked moments with a gap between them.

OperationSafe without our own lock?
items.append(x), items[i], items[i] = xYes, each one is a single atomic operation
d[key], d.get(key), key in d, len(d)Yes, reads without a lock
d[key] = value, d.setdefault(key, v), d.pop(key)Yes, protected by the per-object lock
s.add(x), s.discard(x)Yes, protected by the per-object lock
d[key] = d[key] + 1No, read-modify-write
if key in d: del d[key]No, check-then-act (use d.pop(key, None))
Iterating while another thread changes the containerNo, iterate over a copy such as d.copy()

Both halves of that table show up in one script. Eight threads append 100,000 items each to a single list, and the same eight threads raise a dict value by reading it and writing it back.

THREADS = 8
LOOPS = 100_000

items = []
counts = {"apple": 0}


def append_items():
    for i in range(LOOPS):
        items.append(i)  # one operation, protected by the list's own lock


def count_unsafe():
    for _ in range(LOOPS):
        counts["apple"] = counts["apple"] + 1  # read, add, write = 3 steps
$ python3.14t builtins_atomic.py
GIL enabled: False
Expected: 800000
list.append, no lock: 800000
dict read-add-write, no lock: 190393
dict read-add-write, lock: 800000

Every one of the 800,000 appends survived, and the read-add-write on the dict lost three quarters of its increments. So the rule is about the shape of the call, not the type. One call on a built-in container is safe on its own, and the moment a second call depends on what the first one returned, the pair needs our lock. The thread safety page in the Python docs is the list of what is guaranteed, and anything it doesn’t name gets a lock from us.

5. Free-Threaded Python and C Extensions

Everything so far has been Python code we control. Most projects also pull in a C extension, which is a module written in C and compiled against CPython’s C API, and NumPy, pandas and the database drivers all ship one. An extension built for the GIL was written on the understanding that no other thread touches its data while its code runs, and the free-threaded build breaks that understanding.

Two things therefore have to line up before an extension loads cleanly. It has to be compiled against the free-threaded interpreter, whose binary interface differs from the standard one and is tagged cp314t in the name of the wheel, the pre-built zip file pip downloads instead of compiling from source. It also has to say out loud that it is safe without the GIL, which it does through a module slot, an entry in the table of options a module hands the interpreter at import. The slot is Py_mod_gil and the value is Py_MOD_GIL_NOT_USED. Import an extension that skips the declaration and the interpreter switches the GIL back on for the whole process and raises a RuntimeWarning.

Two small extensions in the example project show both sides, built with gcc against python3.14t. The module legacy leaves the slot table empty, and the module modern fills in the one line below.

static PyModuleDef_Slot slots[] = {
#ifdef Py_GIL_DISABLED
    {Py_mod_gil, Py_MOD_GIL_NOT_USED},
#endif
    {0, NULL}
};
$ python3.14t use_extensions.py
RuntimeWarning: The global interpreter lock (GIL) has been enabled to load module 'legacy', which has not declared that it can run safely without the GIL. To override this behavior and keep the GIL disabled (at your own risk), run with PYTHON_GIL=0 or -Xgil=0.
After importing legacy, GIL enabled: True

$ python3.14t -c "import modern; import sys; print(sys._is_gil_enabled())"
False

One old extension anywhere in the import graph puts the GIL back and the parallel speedup disappears with it, without anything failing. So we call sys._is_gil_enabled() after an application has finished importing and treat a True there as a bug to chase. Setting PYTHON_GIL=0 overrides the interpreter and keeps the GIL off, which is worth doing only when we know the extension is safe and the warning is stale.

For anyone maintaining an extension rather than consuming one, the work is to guard the data the C code owns. CPython offers critical sections for that, which are stretches of C code that hold an object’s own lock, marked by the macros Py_BEGIN_CRITICAL_SECTION and Py_END_CRITICAL_SECTION. Python 3.15 also adds the stable interface abi3t (PEP 803), so one build of an extension can serve several Python versions of the free-threaded build.

Checking our own dependencies is a smaller job. We look for cp314t wheels on PyPI, the index pip installs from, or we read the community compatibility tracker and the free-threaded wheels list, both of which move week by week as packages ship support.

6. Free-Threaded Python Performance

Once the extensions behave, the question left is what the build buys and what it charges. A CPU-bound benchmark answers both at once. The script counts the primes below 100,000 four times through a ThreadPoolExecutor, once with 1 worker and once with 2, so both setups do identical work and only the thread count moves. We measured on a virtual machine with 2 vCPUs, which are 2 virtual processor cores (Intel Xeon at 2.80 GHz), 8 GB RAM and Linux 6.18, on Python 3.14.6 from uv. The machine is shared with other jobs, so every setup ran 15 times and we report the middle time of the 15 and the fastest of the 15.

import time
from concurrent.futures import ThreadPoolExecutor

LIMIT = 100_000
TASKS = 4


def run(workers):
    start = time.perf_counter()

    with ThreadPoolExecutor(max_workers=workers) as pool:
        results = list(pool.map(count_primes, [LIMIT] * TASKS))

    elapsed = time.perf_counter() - start
    assert results == [9592] * TASKS
    return elapsed
$ python3.14 benchmark.py
Python 3.14.6 | GIL enabled: True
1 thread(s): median 0.78 s, best 0.68 s
2 thread(s): median 0.85 s, best 0.76 s

$ python3.14t benchmark.py
Python 3.14.6 | GIL enabled: False
1 thread(s): median 0.85 s, best 0.76 s
2 thread(s): median 0.48 s, best 0.40 s

Three things come out of that run. The standard build got no work out of the second thread and went slower with it, because the two threads pass the GIL back and forth and every hand-off costs a little, which is pure overhead on top of the same total work. The free-threaded build cut that same work roughly in half on two threads, close to the ceiling of 2 cores. And on one thread the free-threaded build trailed the standard one, by 9% on the median and 12% on the best time.

Bar chart of the best time for the same CPU-bound work. The standard build needs 0.68 s with one thread and 0.76 s with two threads, the free-threaded build needs 0.76 s with one thread and 0.40 s with two threads.
On a 2-core machine, two threads made the standard build slower and the free-threaded build 1.9 times faster.

That single-threaded cost sits a little above the range the Python docs give, which is about 5% to 10% on 3.14 and about 8% for x86-64 Linux in the 3.15 free-threading HOWTO. A small shared virtual machine is a noisy place to measure, and the gap is the kind of thing that moves with the compiler and the host load. The table carries the same four pairs of numbers with the speedup worked out against each build’s own single-thread time.

BuildThreadsMedianBestSpeedup over 1 thread (best)
Standard (GIL)10.78 s0.68 s–
Standard (GIL)20.85 s0.76 s0.9x (slower)
Free-threaded10.85 s0.76 s–
Free-threaded20.48 s0.40 s1.9x

Two cores is the smallest machine on which this difference shows up at all. A machine with more cores gives the free-threaded build more room on the same code, and we didn’t measure one.

7. GIL vs Free-Threaded Build

Speed is one row of a longer comparison. The rest of the differences are the ones that decide how we write threaded code and how we deploy it.

TopicStandard build (GIL)Free-threaded build
Executablepython3.14python3.14t
Status in Python 3.14 and 3.15DefaultSupported, optional
Python code in several threadsOne thread at a timeSeveral threads in parallel on several cores
CPU-bound work with ThreadPoolExecutorNo speedupSpeedup up to the number of cores
Single-threaded speedBaselineSlower, about 5-10% on 3.14 according to the Python docs
Memory useBaselineHigher, because objects carry extra per-object state
Built-in list, dict, setOperations serialized by the GILPer-object locks
counter += 1 on shared dataNot atomic, the race rarely showsNot atomic, the race shows often
C extensionsAny extensionMust declare Py_mod_gil, otherwise the GIL is enabled again
Wheel tagcp314cp314t
Runtime switchNot availablePYTHON_GIL and -X gil

8. Free-Threaded Python FAQs

8.1. Does Python 3.14 Remove the GIL?

No. Python 3.14 ships two interpreters side by side. The standard build keeps the GIL and is what we get by typing python3.14, and the free-threaded build runs without it as python3.14t. Python 3.15 follows the same model, and making the free-threaded build the default is left to a future PEP.

8.2. Is Free-Threaded Python Production Ready?

The interpreter is. PEP 779 made the build supported in Python 3.14, which settles the design and the APIs. Whether our own application is ready is a different question, and four conditions decide it.

  • The work is CPU-bound. Code that waits on a database or an HTTP call already scales with threads, and the new build gives it nothing worth the move.
  • Every package in our lock file, which is the file pinning the exact versions an install resolves to, has a cp314t wheel or pure-Python code. One package without it switches the GIL back on.
  • The test suite passes on python3.14t, and sys._is_gil_enabled() returns False after the application’s imports have run.
  • The machine has cores to spare. Two threads on one core gain nothing on either build.

Miss any of the four and the honest answer is to wait. Keep the standard build, and come back to this when the dependency you are blocked on ships its wheel.

8.3. Is Free-Threaded Python Slower for Single-Threaded Code?

Yes, by a few percent. The Python 3.14 release notes put the overhead at about 5% to 10% depending on the platform and the C compiler, and the free-threading HOWTO for 3.15 gives about 1% on macOS aarch64 against about 8% on x86-64 Linux. Our own run is in section 6.

8.4. Do We Need Locks in the Standard Build With the GIL?

Yes. The GIL serializes one operation on a built-in type, and it stops there. It leaves counter += 1 and every check-then-act sequence open to the same interleaving, which section 3 measured on both builds. Code that only works because the race is rare keeps working until somebody runs it on python3.14t.

8.5. How Do We Run the Free-Threaded Build With the GIL Enabled?

We set the environment variable PYTHON_GIL=1, or pass the option -X gil=1 on the command line, as section 2 shows. The call sys._is_gil_enabled() then returns True while Py_GIL_DISABLED still reports the build as free-threaded.

8.6. Threads, multiprocessing or asyncio?

It depends on what the work waits for. Use asyncio for thousands of concurrent network calls, because one thread handles them all and no lock enters the picture. Use multiprocessing or a ProcessPoolExecutor when the tasks are CPU-bound and pass little data, since separate processes have always used every core and they share no memory to corrupt. The free-threaded build changes the third case, CPU-bound work over a large shared structure, which paid the cost of copying that structure into every process until now.

8.7. How Do We Find Race Conditions in Our Own Code?

Start by running the existing test suite under python3.14t, because the build turns rare races into frequent ones and that alone surfaces a surprising number of them. A test that touches shared state then gets a stress wrapper, meaning the same assertion run from more threads and more iterations, repeated in a loop, since a race that fails one run in fifty passes a single run. For the C side, CPython supports building with ThreadSanitizer, a compiler tool that reports unsynchronized access between threads as it happens.

9. Conclusion

Free-threaded Python has been supported since 3.14 and is still the build we opt into rather than the one we get. It lets threads use every core for CPU-bound work, and it charges a few percent of single-threaded speed for that.

The GIL never made our compound operations atomic, so counter += 1 and every check-then-act sequence want a lock on both builds. What the free-threaded build changes is how often we find out. Data that no thread shares needs nothing, which is why we pass work through queue.Queue and return results from ThreadPoolExecutor tasks wherever the shape of the problem allows it, and reach for Lock or RLock for what is left.

We try it on a branch rather than in a release. We install python3.14t, run the test suite, check that sys._is_gil_enabled() is still False after the imports, and measure the CPU-bound path we care about. If a dependency drags the GIL back in, the standard build is still there and nothing was lost.

10. References

Drop me your questions related to free-threaded Python in the comments.

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.