Search nomadLab

Free-Threaded Python 3.14: What Actually Decides If You Get Parallelism

I ran the same threaded workload on 3.14 and 3.14t on a 10-core laptop: 1.00x versus 3.53x on four threads. The interesting part is the two things that silently take that away, and neither of them is Python.

Updated

Python 3.14 shipped on 7 October 2025 with free-threading no longer marked experimental. The GIL was not removed. It was made optional, and you opt in by installing a second interpreter: python3.14t, a separate binary with its own ABI tag and its own wheels.

The question that matters is not whether the speedup is real. It is. The question is whether your process gets it, and that turns out to be decided by two things that have nothing to do with your code. I installed both builds and measured, then went looking for why the wins disappear in practice. Version numbers and release dates below were checked on 22 August 2026.

What I measured

Both builds are CPython 3.14.4 installed with uv python install 3.14 3.14t, running on an M1 Pro MacBook Pro with eight performance cores and two efficiency cores. The workload is pure Python arithmetic (Collatz step counting over a 120,000-integer range per thread), best wall time of five runs, threads started with threading.Thread.

ThreadsGIL buildFree-threaded build
10.778s (1.00x)0.759s (1.00x)
21.561s (1.00x)0.779s (1.95x)
43.124s (1.00x)0.861s (3.53x)
86.247s (1.00x)1.343s (4.52x)

The GIL column is the whole argument in one column. Doubling the threads doubles the wall time, every time, because only one of them is running bytecode at any instant. The free-threaded column gives back 1.95x on two threads and 3.53x on four. Eight threads returns 4.52x rather than something near 8x, which is what happens when macOS starts placing threads on efficiency cores; on a machine with eight identical cores I would expect that number to be higher.

Then I measured the other side of the trade, single-threaded, across five different shapes of work:

WorkloadGILFree-threadedPenalty
Collatz arithmetic380.5ms373.9ms-1.7%
dict building51.6ms59.5ms+15.3%
json.loads44.5ms44.5ms0.0%
re.findall429.8ms463.1ms+7.7%
Object churn (__slots__)108.0ms105.8ms-2.0%

The release notes say the single-threaded penalty is roughly 5 to 10 percent, and that matches what I got on average. It does not match any individual line. Work that spends its time inside a C function (json, re) pays close to nothing extra because the interpreter loop is not where the time goes. Work that allocates a lot of small Python objects pays three times the headline figure. If you have one hot path that decides your p99, the average is not the number you want.

Two things take the parallelism away

Neither of them announces itself in a benchmark. Both of them can happen on a machine where you correctly installed the free-threaded build.

Two gates decide whether a free-threaded Python process runs threads in parallel. First, pip must find a cp314t wheel for each compiled dependency; if not, it builds from source or fails, and an abi3 wheel does not substitute. Second, each extension module must declare the Py_mod_gil slot; if not, importing it switches the GIL back on with a warning. Only when both gates pass do threads run on separate cores. Two gates between installing 3.14t and actually getting cores python3.14t starts with the GIL off Does pip find a cp314t wheel? no: build from source, or fail. An abi3 wheel does not count. Is the module marked Py_mod_gil? no: the GIL comes back on at import, with a printed warning. Threads run on separate cores sys._is_gil_enabled() answers the second gate at runtime, after your imports.
The second gate is the one that catches people. It fires at import time, in a process that started correctly.

The GIL can switch itself back on. A C extension that does not declare the Py_mod_gil slot is assumed to be unsafe without the lock, so importing it re-enables the GIL for the whole process and prints a warning. Your process is now a normal GIL process that also carries the free-threaded build’s single-thread penalty, which is the worst of both. The warning goes to stderr, where in a container it lands in a log nobody reads.

Check it where it counts, after your imports have run:

python3.14t -c "import your_app, sys; print(sys._is_gil_enabled())"

False means you have what you paid for. True means one of your dependencies took it away, and the warning above tells you which. You can also force the question with -X gil=1, which is worth knowing mostly so you can tell when somebody has quietly set PYTHON_GIL=1 to work around a crash and never removed it.

And the wheels have to exist. The free-threaded build uses the cp314t ABI tag. Wheels built for the regular cp314 interpreter will not load, so every compiled dependency needs a separate build.

I checked how bad that actually is rather than guessing. Taking the top 100 packages by download count from the PyPI download statistics snapshot dated 1 August 2026, and reading each project’s current release files from the PyPI JSON API:

Packages
Pure Python (no wheel needed)75
Compiled, ships cp314t wheels22
Compiled, no cp314t wheels3

NumPy, pandas, SciPy, PyArrow, cryptography, Pillow, SQLAlchemy and pydantic-core all ship them. The three that do not are grpcio, protobuf and litellm, and the last two are interesting: they publish cp310-abi3 wheels, built against the stable ABI so one file covers every Python from 3.10 up. That trick does not extend to the free-threaded build. There is no stable ABI for it in 3.14, so a project that standardized on abi3 to stop maintaining a matrix of wheels has, without doing anything wrong, no wheel for you at all.

The thing that changes in six weeks

Python 3.15 is scheduled for 1 October 2026, and it lands PEP 803, a stable ABI for free-threaded builds called abi3t. That is aimed squarely at the gap above. It is not free for maintainers, though: adopting it means moving to the PyModExport_* hook from PEP 793 and away from putting PyObject inside the instance struct, and the docs note that setuptools, meson-python, scikit-build-core and Maturin do not support it yet. So the fix exists in October and arrives in your site-packages some time after that.

The same release upgrades the JIT, which is a separate feature people keep folding into this decision. In 3.14 the copy-and-patch JIT is off by default and not recommended for production. In 3.15 the notes claim an 8 to 9 percent geometric mean improvement on x86-64 Linux and 12 to 13 percent on AArch64 macOS, with a caveat printed directly in the document that the results are not final. Either way it is a different knob. Do not let a JIT number pull you into a free-threading migration, or the reverse.

Getting the binary is more annoying than it should be

There is no free-threaded tag on the official python images on Docker Hub. I went through all of them on 22 August 2026 looking for a 3.14t variant and there is none, so FROM python:3.14t-slim does not exist no matter how many guides print it. Your options are building the interpreter into your own base image, taking a distro package, or uv python install 3.14t, which is what I did and took about fifteen seconds.

Plan for two interpreters coexisting for a while rather than a cutover. The builds install side by side, the binaries have different names, and the wheels are separate downloads, so CI has to test both if you ship a library. That is the real operational cost, and it is larger than the 5 to 10 percent.

Where I would draw the line today

If the work is CPU-bound, pure Python, and splits cleanly across threads, the table at the top is the whole case and it is a good one. Run sys._is_gil_enabled() after importing your actual application before you believe any of it.

If the service is I/O-bound, which most web services are, there is nothing here for you this year. The GIL was never what was limiting a process waiting on sockets, and you would be paying a single-thread penalty for parallelism you do not use.

If you have a compiled dependency nobody upstream maintains, that is where the real risk sits, and it is not the risk of a crash. A C extension written under the assumption that only one thread touches Python objects fails by computing something slightly wrong under load, months later. Check the Py_mod_gil marking on every extension in your tree, and if something is unmarked, keep it unmarked rather than forcing the issue.

The measurement worth doing this week is the cheapest one: install 3.14t next to what you have, import your application, and print sys._is_gil_enabled(). If it prints True, you have your answer without benchmarking anything.

Keep reading