Python 3.15 free-threaded: 3.8x faster, but 85% of counts lost
By Nihar Ranjan Das · Thu Oct 22 2026 · 11 min read · 0 views
View as a Web StorySoftware#python#free threading#gil#performance#concurrency

Python 3.15's free-threaded build ran pure-Python work 3.8x faster on 8 threads, and the standard build ran it 1.0x. The same build also lost 85% of the increments to an unlocked shared counter, where the standard build lost none. Free threading is real speed for CPU-bound web work, but code that quietly relied on the GIL can now return wrong answers.
This post gives the measurements for four workloads, the cost of the free-threaded build on one thread, the counter bug, and three fixes with their timings.
What is free-threaded Python, and what does 3.15 change?
Free-threaded Python is a build of CPython with the global interpreter lock turned off, so several threads can run Python bytecode at the same moment. The GIL, short for global interpreter lock, is a mutex that lets only one thread execute Python code at a time. PEP 703 proposed making it optional, and the official free-threading HOWTO documents the result.
You get the free-threaded build as a separate interpreter. Homebrew is the macOS package manager, and its python-freethreading package installs python3.15t, where the t stands for threaded. The What's New in Python 3.15 page adds that the official macOS binaries now install free-threading support by default. At runtime, sys._is_gil_enabled() tells you which mode you are in. The HOWTO says you can force the GIL back on with the PYTHON_GIL environment variable or the -X gil option, and that importing a C extension that does not declare support re-enables the GIL.
The question for a web developer is simple. Does a threaded Python server, such as a worker pool that parses JSON and hashes payloads, get faster? And what breaks? We measured both. This post pairs with Python 3.15 makes UTF-8 the default. What breaks?, which covers the other 3.15 change that can bite on upgrade.
How we measured it
The test setup and methodology are listed below.
The test setup was an Apple M2 laptop with 8 GB of RAM, with 4 performance cores and 4 efficiency cores. We compared Python 3.15.0 standard (python3.15) and 3.15.0 free-threaded (python3.15t), both from Homebrew.
Each workload had a fixed total amount of work, split evenly across 1, 2, 4 and 8 threads. We used ThreadPoolExecutor, a standard-library class that runs submitted calls on a fixed pool of worker threads. We report the median of three runs after a warm-up. The four workloads were:
- Pure Python. A loop of 8 million integer operations, which is the shape of a hand-written scoring or parsing routine.
- JSON. 4,000 calls to
json.loadson a 200-record document of about 14 KB, a typical API payload. - SHA-256. 4,000 hashes of a 64 KB buffer with
hashlib. - Sort. 400 sorts of 2,000 random floats.
The speedup is the one-thread time divided by the N-thread time. A perfect speedup on N threads would be N.
Results: do threads scale without the GIL?
On the free-threaded build, CPU-bound threads scaled to between 3.8x and 4.9x on 8 threads. On the standard build, three of four workloads did not scale at all.
Advertisement

| Workload | Standard, 4 threads | Standard, 8 threads | Free-threaded, 4 threads | Free-threaded, 8 threads |
|---|---|---|---|---|
| Pure Python | 1.00x | 1.01x | 3.79x | 3.83x |
| JSON parse | 1.01x | 1.00x | 3.54x | 4.49x |
| SHA-256 | 3.64x | 2.68x | 3.64x | 4.86x |
| Sort | 1.02x | 1.02x | 3.78x | 4.25x |
SHA-256 is the exception, and it is instructive. The standard build already scales on hashing, 3.64x on 4 threads, because hashlib releases the GIL while it hashes large buffers. So code that spends its time inside C libraries that drop the GIL already had parallelism. Free threading helps most where the time is spent in Python code itself. For example, a worker that validates 10,000 records in a Python loop gains a lot, while a worker that hands every record to a compiled library gains little.
The speedup stops near 4x on 4 threads because the M2 has four performance cores. The jump to 4.5x on 8 threads comes from the four efficiency cores, which are slower. On a 16-core server, expect the pure-Python line to keep climbing toward the core count, though memory bandwidth and contention will cap it.
Standard 8-thread SHA-256 fell to 2.68x from 3.64x at 4 threads. That is scheduling noise from oversubscribing the performance cores, and it shows why thread counts should match cores rather than request counts.
What does the free-threaded build cost on one thread?
The free-threaded build ran 10% to 12% slower on a single thread for Python-heavy code (our lab, 2026). That tax is what you pay for the option, and it matters for services that do not use many threads.
| Workload | Standard, 1 thread | Free-threaded, 1 thread | Slower by |
|---|---|---|---|
| Pure Python | 0.363 s | 0.402 s | 10.7% |
| JSON parse | 0.361 s | 0.404 s | 11.9% |
| SHA-256 | 0.102 s | 0.102 s | 0% |
| Sort | 0.093 s | 0.102 s | 9.7% |
The HOWTO says the average overhead across pyperformance, the official suite of Python benchmark programs, ranges from about 1% on macOS aarch64 to 8% on x86-64 Linux. Our tight loops sit above that range (our lab, 2026), which is expected for microbenchmarks that never leave the interpreter. A real service that waits on a database spends less of its time in the interpreter, so it pays less.
The decision follows from the numbers. If your service can keep two or more cores busy with Python code, the free-threaded build wins. If it runs one process per core and no threads, the standard build is faster.
The bug: an unlocked counter loses 85% of its updates
On the free-threaded build, eight threads running counter += 1 on a shared value lost 6.8 million of 8 million updates. On the standard build, the same code lost none.
Here is the code:
import threading
from concurrent.futures import ThreadPoolExecutor
counter = [0]
def work(n):
for _ in range(n):
counter[0] += 1
with ThreadPoolExecutor(8) as ex:
list(ex.map(work, [1_000_000] * 8))
print(counter[0]) # standard: 8000000 free-threaded: about 1,200,000
We ran it three times. The free-threaded build lost 6,807,459, 6,794,823 and 6,815,184 updates, which is 85% each time (our lab, 2026). The standard build returned 8,000,000 every time.
A race condition is a bug where the result depends on how threads happen to interleave. The reason is that counter[0] += 1 is three steps: read, add, write. With the GIL, the interpreter rarely switches threads between those steps, so the race hides in practice. Without the GIL, threads interleave constantly, and two threads read the same value and write back the same result.
This is the real migration risk. A web app can contain a global request counter, an in-process cache dict that is checked and then filled, or a list that threads append to and then read. Under the GIL these mostly worked. The HOWTO says built-in types such as dict, list and set use internal locks to protect themselves, but it recommends that you use threading.Lock or other synchronization primitives rather than rely on those internal locks. It also warns that sharing one iterator object across threads is not thread-safe. Individual operations are safe. Sequences of operations are not.
The same lesson applies to caches. A check-then-set on a shared cache, like the one in Node LRU cache: the Map trick is 25x slower than lru-cache, needs its own lock in any runtime where threads truly run in parallel.
Three fixes, with timings
A lock fixes the lost updates but costs time, and a thread-local count fixes both. We measured three versions with 8 million increments in total.
| Version | 1 thread | 8 threads | Lost updates (8 threads) |
|---|---|---|---|
Unlocked counter += 1 |
0.242 s | 0.356 s | 6.8 million |
| Lock on every increment | 0.555 s | 0.800 s | 0 |
| Thread-local count, merge once | 0.157 s | 0.048 s | 0 |
The lock is correct and slow. On 8 threads it took 0.80 seconds, 2.2 times longer than the broken version (our lab, 2026), because the threads queue on one lock. A lock around a hot loop turns a parallel program into a serial one.
The thread-local version is correct and fast: 0.048 seconds, a 7.4x improvement over the broken code (our lab, 2026). Each thread counts into a local variable, then adds its total under the lock once.
import threading
lock = threading.Lock()
total = 0
def work(n):
global total
local = 0
for _ in range(n):
local += 1 # no shared state in the hot loop
with lock:
total += local # one lock acquisition per thread
The third option is to avoid sharing. Give each worker its own result and combine them in the caller, as ThreadPoolExecutor.map already does with return values. For a request counter, keep one counter per thread and sum them when something reads the metric.
The standard build did not gain from the thread-local version (0.131 s on 8 threads, against 0.133 s on one), which is the GIL at work. The same code is correct on both builds, and fast only on one.
How do you test your own service in ten minutes?
Run one representative handler on 1, 2, 4 and 8 threads on both builds and compare the speedup. The script below does that for any function, and it takes under a minute for a handler that runs in a few milliseconds.
import sys, time
from concurrent.futures import ThreadPoolExecutor
def bench(fn, calls=2000):
for threads in (1, 2, 4, 8):
per = calls // threads
t0 = time.perf_counter()
with ThreadPoolExecutor(threads) as ex:
list(ex.map(lambda _: [fn() for _ in range(per)], range(threads)))
print(threads, round(time.perf_counter() - t0, 3), "s")
print("GIL enabled:", sys._is_gil_enabled())
bench(lambda: your_handler(sample_request)) # replace with your handler
Read the result with three questions. Does the 8-thread time drop below half of the 1-thread time? Does the 1-thread time on the free-threaded build stay within 15% of the standard one? And does your own function return the same results on both? If all three are yes, a pilot is safe. If the first is no, your handler spends its time outside Python code, in a database or a C library that already drops the GIL, and free threading will not help.
Run the pilot on one instance behind a load balancer, not on the whole fleet. Compare p95 latency and CPU use for a day. A p95 latency is the response time that 95% of requests beat. A threaded service that was CPU-bound on one core should use more cores and show a lower p95 under load, so size the container's CPU limit to match.
Should you switch a web service to free-threaded Python?
Switch only when you have CPU-bound Python on several threads, your dependencies support the build, and your tests cover shared state. For most I/O-bound services, the standard build is still the right default.
- CPU-bound code on 2 or more cores, in one process: try free-threaded. Our measurements show 3.8x to 4.5x on 8 threads.
- Mostly waiting on a database or HTTP calls: stay on the standard build. Threads already overlap their waiting, and the single-thread tax of 10% buys nothing.
- One process per core with no threads, such as a typical gunicorn setup: stay. You already use every core, and you would pay the 10% tax for nothing.
- Heavy use of C extensions: check support first. A C extension is a compiled module, written in C or Rust, that plugs into the interpreter. The 3.15 release notes list PEP 803, a stable ABI for free-threaded builds, which should make it easier for extension authors to ship compatible wheels, but your own dependencies still decide the result. The HOWTO says an extension that does not declare free-threaded support makes Python re-enable the GIL when it loads, which removes the benefit without any error.
To check what you actually got, print the mode at startup and fail loudly if it is not what you expect:
import sys
assert not sys._is_gil_enabled(), "GIL got re-enabled, probably by a C extension"
Before switching, audit shared mutable state. Search for module-level dicts and lists that handlers write to, global counters, lazy initialization such as if cache is None: cache = build(), and any iterator shared by threads. Add a lock, or move the state into thread-local storage.
A cheap test is to run your test suite with 8 threads on python3.15t and compare totals against the standard build. If a counter, a set size or a cache hit count differs, you found a race. Our counter test fits in 15 lines and found a difference on the first run. If your own numbers differ from ours, contact the NiharDaily editor with your CPU model and workload, and we will compare them with the lab.
The bottom line: faster threads, stricter rules
Free-threaded Python 3.15 delivered 3.8x to 4.9x on 8 threads where the standard build delivered 1.0x for Python-heavy work (our lab, 2026). It costs about 10% on one thread, and it removes the accidental safety the GIL gave to unlocked shared state.
Use it for CPU-bound threaded services after you audit shared state. Keep the standard build for I/O-bound services and one-process-per-core deployments. Whichever you pick, stop writing counter += 1 on shared data.
Advertisement
FAQ
Is free-threaded Python faster than regular Python?
Only with several busy threads. In our Python 3.15 test, pure-Python work ran 3.83 times faster on 8 threads on the free-threaded build and 1.01 times on the standard build. On a single thread, the free-threaded build was 10 to 12 percent slower, so single-threaded code gets worse.
How do I install free-threaded Python 3.15?
Install the separate free-threaded build, which is a different interpreter named python3.15t. On macOS, Homebrew provides it as python-freethreading. Confirm the mode in Python with sys._is_gil_enabled(), which returns False when the GIL is off. Create virtual environments from that interpreter.
Is counter += 1 thread-safe in free-threaded Python?
No. It is a read, an add and a write, and threads can interleave between them. In our test, 8 threads each adding 1 million lost 6.8 million of 8 million updates on the free-threaded build. Protect shared counters with a lock or keep per-thread counts and merge them.
Why did free-threaded Python re-enable the GIL?
Importing a C extension that does not declare free-threaded support makes Python switch the GIL back on, according to the official free-threading HOWTO. Check sys._is_gil_enabled() after your imports. Upgrade or replace the extension, or accept the GIL and the loss of parallelism.
Does free-threading help I/O-bound web apps?
Rarely. Threads in a standard build already overlap while they wait on a database or network call, because waiting releases the GIL. Free threading adds the most for CPU-bound Python code, and it costs about 10 percent on a single thread, so most I/O-bound services should stay on the standard build.
Comments
Loading…
Sign in to join the conversation.
Related posts

Node.js October 2026 security release: all 10 CVEs ranked
Upgrade to Node.js 26.12.1, 24.22.1 or 22.23.4 this week, and rebuild any snapshot or single executable binaries afterwards. The October 21, 2026 release fixed ten CVEs. Four are rated high, three
Thu Oct 22 2026 · 10 min read · 0 views

Is your PHP vulnerable to CVE-2026-109618? A setup check
Yes, if you run PHP 8.5 below 8.5.12, 8.4 below 8.4.27, 8.3 below 8.3.36 or 8.2 below 8.2.35, and you serve web requests. PHP released those fixed versions on October 21, 2026 to close two critical
Thu Oct 22 2026 · 10 min read · 0 views

Node.js Brotli CVE-2026-111497: is your app exposed?
Upgrade to Node.js 26.12.1, 24.22.1 or 22.23.4, then check whether your code decodes Brotli data it did not produce. CVE-2026-111497 is a Brotli decoder flaw that Node.js rates high and says permits
Thu Oct 22 2026 · 10 min read · 1 views