Python dropped the GIL. Everything you marked 'thread-safe' lied.
Published by RodHat

Python’s GIL is gone. Optional as of 3.13, flipping toward default in the distribution packages that actually matter. Two years of free-threaded Python builds in the wild have produced a predictable result: all the code that claimed to be thread-safe is turning out not to be.
I don’t want to hear that you’re surprised. You had thirty years of warning.
What the GIL actually was
The Global Interpreter Lock is a single mutex — one lock — that CPython
acquired before executing any bytecode and released only for blocking I/O and
explicit ALLOW_THREADS macros in C extensions. One thread running at a time,
full stop. This was not a bug. Guido made this call deliberately in 1992 because
Python’s reference-counting memory model requires that ob_refcnt increments and
decrements be atomic. With a single lock, that’s trivially true. Without it,
you need either atomic operations on every reference count mutation or a more
sophisticated GC. Both cost cycles.
The deal the GIL struck was: you get a correct interpreter, you get all C extensions that don’t bother with thread safety “just working,” and you give up real parallelism on the CPU. For I/O-bound work — web requests, database calls, anything blocking — this is a fine trade. For CPU-bound work, it means your eight-core machine runs your Python worker on one core and the other seven watch.
That deal is now optional. PEP 703, authored by Sam Gross and accepted in 2023
after his nogil fork proved the performance delta was manageable, describes
the path. The free-threaded build (python3.13t, or any build with
--disable-gil) removes the lock and replaces per-object reference counts with
a biased reference counting scheme — thread-local counts most of the time, a
shared fallback path on contention. It is not free, but it’s cheap enough that
the tradeoff flips for parallel workloads.
The interpreter is correct. Correct at the C level. Whether your Python code is correct is your problem.
The class of bugs you have now
Here is the nature of the trap. The GIL did not make your Python code thread-safe. It made many Python operations effectively atomic as a side effect of the lock granularity, and it made race conditions rare enough that they almost never manifested in practice.
Specifically:
dict operations are no longer implicitly atomic. In CPython with the GIL,
d[k] = v is fast enough relative to the GIL release cycle that concurrent
mutations of the same dict rarely produced visible corruption. Without the GIL,
two threads writing different keys can corrupt the internal hash table state.
The threading.Lock you didn’t wrap around that dict access because “it’s just
a cache” is now load-bearing.
List append() is no longer safe from the Python level. Again, the GIL
made it fast enough to look safe. Concurrent appends to the same list without
synchronization can now produce a corrupted list object, not just a reordering
of elements.
Module-level global state. This is the big one. How many modules in your stack keep a module-level cache, connection pool, or singleton that gets lazily initialized on first import? With the GIL, the initialization was fast enough that two threads racing to initialize it almost always resolved cleanly. Without it, you can get double-initialization, partial initialization visible to other threads mid-setup, or a destructor racing with a constructor on the same object.
C extensions that never declared thread safety. This is arguably worse than
the Python code problem. The ecosystem is full of C extensions that were written
assuming the GIL. When you import them in a free-threaded build, CPython
currently serializes those extensions with a per-module lock (the Py_MOD_GIL_NOT_USED
flag has to be explicitly set by the extension author to opt out). But extensions
that haven’t been updated at all just get the lock by default — which means your
free-threaded build has a GIL again, just a smaller one scoped to that module.
The transition is incomplete, and it’ll stay incomplete for years.
How to find out what you own
# CPython 3.13+: run with GIL disabled
python3.13t your_script.py
# Or on a standard 3.13+ build:
python3 -X gil=0 your_script.py
# Check at runtime whether you're actually running without it:
import sys
print(sys._is_gil_enabled()) # False = you're exposed
# ThreadSanitizer is not Python-native but if your C extensions
# use pthreads, TSan will catch the races your Python profiler won't:
# PYTHONMALLOC=malloc TSAN_OPTIONS="halt_on_error=1" python3.13t your_script.py
The most useful single thing you can do is run your test suite under
python3.13t with a reasonably high thread count. If you have race conditions
in production, the test suite will trigger them — probably not reliably, but
often enough to find them faster than waiting for a 3am page.
-X gil=0 on a standard build will tell you at startup if any imported
extension module is forcing the GIL back on. That output is your prioritized
list of C extensions to either update, replace, or sandbox.
If you’re using bpftrace to investigate the live behavior of a Python process
— say, you’re trying to confirm whether two threads are actually touching shared
state concurrently — the bpftrace one-liner patterns
apply here the same as anywhere else. The USDT probes in CPython 3.12+ let you
trace function__entry and function__return across threads with minimal
overhead.
The verdict
Removing the GIL was right. It was always right; the question was whether the performance penalty and C extension compatibility cliff were acceptable, and Sam Gross spent years demonstrating they could be managed. This is not a case where I’m going to spend the whole post being grumpy and then admit it at the end — the decision to remove it was obviously correct from first principles and the only argument against it was pragmatic inertia.
The argument against the rollout is different. Shipping a free-threaded build
as python3.13t — a separate binary, opt-in, clearly marked as experimental —
was exactly right. Letting distributions flip it to default before the C
extension ecosystem has a clear picture of what’s opted in and what’s still
serializing through a module-level lock is the part I’d push back on.
The race conditions you have now were always there. The GIL was suppressing them probabilistically, not eliminating them. Every docstring that said “thread-safe” without specifying which operations and under what conditions was a promissory note that the GIL was quietly covering. It’s not covering it anymore.
Audit your shared state. You know where it is. You knew where it was when you wrote the comment.
Sources
- PEP 703 – Making the Global Interpreter Lock Optional in CPython — Python Software Foundation
- What's New In Python 3.13 – Free-threaded CPython — Python Software Foundation