Free-threaded Python in production: what actually broke
For fifteen years the answer to "why is my Python threaded code not faster" was one word. The global interpreter lock let one thread run Python bytecode at a time, and for CPU bound work the threads took turns rather than sharing the cores. Everyone worked around it. Multiprocessing, Celery workers, rewriting the hot loop in C, or accepting it.
PEP 703 removed the lock behind a build flag in 3.13, and 3.14 last October promoted the free-threaded build to officially supported under PEP 779. The single-threaded overhead, which was around 40 percent in 3.13 and the reason nobody ran it for real, is down to single digits on Linux and macOS. It is still not the default build. You have to install python3.14t and opt in.
I did, for one service, and I want to write down what happened, because the benchmark posts tell you about the speedup and skip the part where your code was never actually thread safe and the lock was hiding it.
The service
It is an ingestion worker. It pulls batches of documents from a queue, parses them, normalises the text, computes some statistics, and writes results to Postgres. Parsing and normalising are pure Python and CPU bound. The service ran as eight processes under a supervisor, each single threaded, each holding its own database connection pool and its own copy of a 600 MB lookup table in memory. Eight copies of 600 MB is most of the box.
The hope with free threading was simple: one process, eight threads, one copy of the table, one pool. Same throughput, a fifth of the memory.
The number
On the parsing benchmark, one process with eight threads on 3.14t did the batch in 31 percent of the time a single thread took, roughly the 3.1x that the release notes quote for multi-threaded CPU work. Not 8x, because the parser has a shared cache with a lock in it and the threads contend on it, and because memory bandwidth on that box is what it is. Still, that is a number I could never get from threads before.
Single threaded, the same code ran about 6 percent slower on 3.14t than on the standard 3.14 build. That matches the documented overhead and it is the tax you pay everywhere else in the process for the parallelism in the hot loop. For this service it was worth it. For a service that is mostly waiting on I/O, it is not, and asyncio was already the right tool there.
Memory went from 8 processes at about 900 MB each to one process at 1.4 GB. That was the actual win.
Bug one: the counter that lied
The service tracks per-document-type counts for a metrics endpoint. The code was this:
counts: dict[str, int] = defaultdict(int)
def record(doc_type: str) -> None:
counts[doc_type] += 1That line has been "thread safe" in CPython for a very long time, in the sense that the GIL made the read-modify-write on the dict entry effectively atomic most of the time. It was never guaranteed. It just never bit anyone, because the interpreter switched threads at bytecode boundaries and this operation was short enough to almost always fit.
On the free-threaded build, two threads recording the same type at the same moment both read 41, both write 42, and one increment is gone. After a day of running, the metrics endpoint was under-reporting by about 2 percent. No exception, no crash, just a number that was wrong.
The fix is a lock, or itertools.count, or moving the counter into the thread and merging at the end. I did the last one, because the lock on a hot counter would have contended.
_local = threading.local()
def record(doc_type: str) -> None:
if not hasattr(_local, "counts"):
_local.counts = defaultdict(int)
_register(_local.counts)
_local.counts[doc_type] += 1The lesson is not about counters. It is that a great deal of Python code is correct under the GIL by accident, and the free-threaded build turns the accident off. The docs have a page on this, "Python support for free threading", and the section on which operations are still atomic is the part to read twice. Individual dict and list operations are protected by per-object locks. Compound operations, like read then write, are not, and they never were.
Bug two: the C extension that said it was fine
The normaliser uses a Unicode library with a C extension. The wheel had the cp314t tag, it installed cleanly, and it declared free-threading support through Py_mod_gil. I took that as a yes.
Under load, about one batch in ten thousand came back with a string that was a mix of two inputs. The extension had a static buffer it reused between calls. Under the GIL, two calls could never overlap. Without it, they could, and did.
The wheel tag means the extension was built for the free-threaded ABI. It does not mean the author audited the code for shared state. Those are different claims, and this year they are being conflated constantly. The extension had a fix upstream within a week of the issue, which is the good news, and the compatibility tracker that the community runs has been the most useful page on the internet for this migration. Before you trust an extension, check whether its entry there says "builds" or "tested".
Until the fix landed I wrapped the call in a lock. That reintroduced a GIL around one function, which is the honest description of what you do when you find one of these: you put the lock back, in the smallest scope that works, and you carry on.
Bug three: the test suite that was single threaded
This one is on me. The test suite ran under pytest, in one thread, and passed on 3.14t on the first try. That gave me confidence I should not have had, because a test suite that never runs two things at once cannot find a race.
What found the first two bugs was production traffic. What should have found them is a stress test that runs the real code paths from many threads with assertions on the aggregate results. I wrote one after the fact. It is not sophisticated:
def test_record_is_consistent_under_threads():
n_threads, n_each = 16, 50_000
barrier = threading.Barrier(n_threads)
def worker():
barrier.wait()
for _ in range(n_each):
record("invoice")
threads = [threading.Thread(target=worker) for _ in range(n_threads)]
for t in threads: t.start()
for t in threads: t.join()
assert total("invoice") == n_threads * n_eachThe barrier is the important part. Without it, threads start staggered and the window for the race is small. With it, sixteen threads hit the same line at the same instant and the lost updates show up in seconds. This test fails on 3.14t against the original code every time and passes against the fixed code. It also passes against the original code on the GIL build, which is the entire point of writing it.
If you are moving anything to the free-threaded build, write the barrier test for every piece of shared mutable state before you flip the switch. It is the only kind of test that tells you something.
What did not break
Most of it. The web layer on the service, which is a small FastAPI app for health and metrics, ran without changes. SQLAlchemy 2 and psycopg 3 both support the build and the connection pool behaved. NumPy has been fine since 2.1. The logging module is safe. concurrent.futures did what it always did, only now the thread pool executor is actually parallel for CPU work, which is a strange thing to type after fifteen years.
The interpreter did not crash once in six weeks. The thing people feared about removing the lock, that the runtime itself would be unstable, did not happen for me. The instability was all in my code and in one extension, which is where the free-threading team said it would be.
One more tool
python -X dev combined with the free-threaded build's PYTHON_GIL=0 environment variable, and a run of the suite under pytest-run-parallel, which executes each test body concurrently from several threads, is the closest thing to a free-threading linter that exists today. It found the counter bug in the first run once I knew to run it. It would not have found the C extension one, because that needs the real workload's overlap pattern, but it turns the barrier test idea into something you get for every test without writing one.
Should you
If your service is I/O bound, no. asyncio already gives you concurrency without threads and the free-threaded build just costs you the single-thread tax.
If your service is CPU bound and already uses multiprocessing successfully, probably not yet, unless the memory duplication is hurting you the way it was hurting me. Processes are still the safest way to get parallelism in Python, because they share nothing and cannot race.
If you have CPU bound work, shared read-mostly state that you are duplicating per process, and a small enough codebase that you can audit every piece of shared mutable state, then yes, and 3.14t is stable enough to run. Read the free-threading HOWTO. Check every C extension on the compatibility tracker. Write the barrier tests. Expect to find two or three places where the lock was doing your job for you.
3.15 is expected to round out the ABI work and the talk is that the free-threaded build becomes the default sometime after. When it does, every Python codebase in the world is going to go through what I went through in March, and the code that was correct by accident is going to stop being correct. Better to find out on one service, on purpose, with a test that fails on the right line.