Python Threads, the GIL, and Free-Threaded CPython
A thread pool that scales on network calls and flatlines on arithmetic is not a mystery. It is the Global Interpreter Lock doing exactly what it was…

Key topics
A thread pool that scales on network calls and flatlines on arithmetic is not a mystery. It is the Global Interpreter Lock doing exactly what it was designed to do.
You have probably seen the first half of that sentence in production. A service fans out a hundred HTTP requests across a thread pool, and throughput climbs. Swap the payload for a CPU-bound transform, and the same pool burns a full core's worth of throughput no matter how many workers you add. The GIL is the reason.
The newer symptom is stranger. You install a free-threaded CPython build, run the same code, and watch it run in parallel — until some dependency imports a C extension, the interpreter re-enables the GIL, and your parallelism evaporates mid-process. No crash. No exception. Just a warning that most test runners never surface.
Both symptoms point at the same question, and it is not "are threads fast?" It is: which assumption is load-bearing in your program — that the interpreter serializes bytecode for you, or that your own code is correct without that serialization? Free-threading changes the answer to the first. It does nothing for the second.
What the GIL Actually Serializes
The Global Interpreter Lock is one lock per interpreter process. It guards execution of Python bytecode and the internals of CPython itself, including the reference-count updates that live inside every object. In CPython, everything is an object and everything is shared, so "protect the internals" means "protect a small piece of mutable data attached to every value your program touches."
That scope is narrower than most engineers assume. The GIL is released around blocking I/O and inside many C extensions that explicitly drop it before entering a long computation. This is why threaded network clients and some numeric kernels already scale across cores today: the lock is not held during the wait, and it is not held during the parts of the kernel that opted out.
What the GIL does not do is protect your application invariants. You do not control when the interpreter releases and reacquires the lock. A check-then-act sequence on shared state — read a counter, decide, write it back — can be interrupted between the read and the write. The lock was never a transaction boundary around your logic.
The GIL makes CPython internals safe. It does not make your program correct. Those are different claims, and conflating them is the most expensive misconception in threaded Python.
The observable consequence is simple. CPU-bound pure-Python threads add context-switch cost without adding throughput, because only one thread executes bytecode at a time. I/O-bound threads overlap because the lock is not held while a thread waits on a socket. Same API, opposite economics.
Knowledge check
Check your understanding
Answer this question before you continue.
The Decision Axis: Serialized Bytecode vs. Parallel Bytecode
Before comparing features, fix the axis. Free-threaded CPython is not "threads, but better." It is a different execution model, and the differences live on four independent axes.
| Axis | GIL build | Free-threaded build |
|---|---|---|
| Can two threads execute Python bytecode at once? | No | Yes |
| What does the runtime guarantee about shared mutable objects? | No interpreter crash | No interpreter crash |
| Single-thread cost of the change | Baseline | Additional interpreter overhead, workload- and platform-dependent |
| What happens when a dependency is not ready? | Not applicable | The GIL can be re-enabled at runtime |
Read the second row carefully. Both builds aim for the same guarantee: the interpreter will not corrupt itself. Neither build promises deterministic results for your shared state. That distinction is the whole article in one row.
The axes are independent, which means you can win on one and lose on another inside the same process. You can gain bytecode parallelism and pay single-thread overhead. You can disable the GIL and then have a dependency switch it back on. There is no single "free-threading on/off" state to reason about — there is a build configuration, a runtime state, and a dependency graph, and all three can disagree.
Knowledge check
Check your understanding
Answer this question before you continue.
What Free-Threading Changes in CPython
Free-threading is a build variant, not a flag you flip on a normal interpreter. When CPython is built with the GIL disabled, the single global lock is replaced by finer-grained per-object locking and critical sections that recreate GIL-like semantics locally. Those critical sections are not ordinary locks; they are designed to be deadlock-free by construction, because the semantics of the GIL are being recreated inside them.
Built-in containers — dict, list, set — use internal locks so concurrent mutation does not corrupt the interpreter. This is worth stating precisely: it is a description of current implementation behavior, not a language guarantee. Python has not historically promised specific behavior for concurrent modification of these types, and the documentation is explicit that you should prefer threading.Lock over relying on the internal locks.
The runtime GIL switch is the part that bites. A free-threaded build can run with the GIL enabled at runtime, either explicitly or automatically when you import a C-API extension that has not declared free-threading support. When that happens, a warning is printed and the GIL comes back for the process.
import sys, sysconfig
# Does this build support free-threading at all?
print(sysconfig.get_config_var("Py_GIL_DISABLED")) # 1 if the build supports it
# Is the GIL actually disabled in this running process?
print(sys._is_gil_enabled()) # False means free-threaded execution is live
Those two checks answer different questions, and you need both. The config variable tells you what the build can do. sys._is_gil_enabled() tells you what the process is currently doing. A process can start free-threaded and lose it the moment a dependency imports.
Iterators are the concrete gap worth internalizing. Sharing one iterator object across threads is not thread-safe, and threads may see duplicate or missing elements — even though the interpreter does not crash. The container is protected; the iteration protocol over it is not.
Knowledge check
Check your understanding
Answer this question before you continue.
The Dependency Boundary: C Extensions and GIL Re-Entry
Free-threading is an ecosystem property, not an interpreter property. Extension modules must opt in. Until they do, importing them re-enables the GIL for the whole process and emits a runtime warning.
That warning fires at import time, which is exactly where test runners and lazy imports hide it. A module imported inside a fixture, a plugin loaded on first use, a lazily-imported accelerator — any of these can flip your process back to serialized execution after your tests have already started passing. This is a real operational failure mode, not a theoretical one.
The hard case is not pure Python. Pure Python is the easy path: object-level locks replace the GIL on fundamental data structures, and code that does not share mutable state across threads behaves much as it did. The hard case is low-level code — C, C++, Cython — that relied on the GIL for reference-count correctness or borrowed references. A borrowed reference is valid only as long as nothing else can free the object; under the GIL that was often true by accident. Without it, the same code can be wrong in ways that surface rarely and catastrophically.
Treat a GIL re-enable warning as a hard CI failure, not a log line. If your build silently reverts to serialized execution, every benchmark you run afterward is measuring the wrong interpreter.
One honest caveat: a package advertising free-threading support is a claim about tested usage, not a proof that every multithreaded call pattern is safe. The flag means the maintainers believe the module is safe under the patterns they test. Your pattern may not be one of them.
Knowledge check
Check your understanding
Answer this question before you continue.
Where the Speedup Actually Comes From
The workload that benefits is the one the GIL was serializing: pure-Python, CPU-bound work spread across threads. That is the clearest win, and it is the case free-threading was built for.
I/O-bound work is the case where free-threading changes almost nothing, because the lock was already released during the wait. If your bottleneck is network or disk latency, a thread pool on the standard build already overlaps it, and asyncio remains the cheaper coordination model for that shape — fewer threads, less memory, explicit backpressure. Reaching for free-threading here buys you overhead you did not need.
Multi-core throughput gains are real but bounded by the single-thread overhead of the free-threaded build. That overhead varies by workload and platform, and it is the tax you pay on every core, including the ones doing serial work. The net win is parallelism minus that tax, and for lightly-threaded code the tax can exceed the gain.
Oversubscription does not disappear when the GIL does. Past the core count, cache thrashing and context-switch cost still degrade throughput. Research on edge configurations makes the point sharply: on quad-core hardware, a free-threaded build showed large throughput gains over the GIL build, but on single-core devices both configurations saturated in similar patterns — context-switch overhead remained the fundamental bottleneck. More threads is not more speed, with or without the lock.
Single-core devices see little benefit from removing the GIL, because the lock was never the constraint there. The constraint was one core.
Thread Safety You Now Own
Removing the GIL removes an accidental safety net that most programs never audited. The net was thin — it never protected your invariants — but it did serialize enough execution that some latent races never fired. Now they can.
Use explicit synchronization primitives rather than relying on the internal locks of built-in types, even where those locks currently prevent corruption. Then audit the patterns that were always racy and merely hidden:
- Read-modify-write on a shared counter, dict, or cache. The classic silent race.
- Shared iterators across threads. Duplicate or missing elements, no crash.
- Shared file handles and connection objects. Treat them as per-thread resources unless the library documents otherwise.
A useful drill, and one I would run before touching a free-threaded interpreter: shrink the thread switch interval on the standard GIL build and run your existing multithreaded tests. A shorter switch interval forces more frequent lock handoffs and surfaces latent races on the build you already trust. If your tests go red there, they were always going to go red — you just had not scheduled the interleaving yet.
import sys
sys.setswitchinterval(1e-6) # aggressive switching to expose latent races
Keep the distinction sharp: "will not crash the interpreter" and "will produce the result you expect" are different guarantees. The free-threaded build targets the first. The second is your job, and it was always your job.
When Not to Reach for Free-Threading
The newer execution model is not automatically the better one. Several shapes argue against it.
I/O-bound services. asyncio or a thread pool on the standard build is usually the lower-risk choice. You already get overlap, and you avoid the single-thread overhead and the dependency-readiness problem entirely.
CPU-bound work already vectorized inside a C extension. The extension may release the GIL today, so you may already have the parallelism you are shopping for. Measure before migrating.
Isolation-heavy workloads. Processes or subinterpreters may fit better than shared-memory threads. These approaches are complementary, not competing — a free-threaded main interpreter can hand problematic modules to a GIL-protected subinterpreter, and a GIL-protected main interpreter can push CPU-bound threads into a dedicated free-threaded subinterpreter. The ecosystem is pursuing both because their strengths compensate for each other's weaknesses.
Ecosystem-blocked projects. If your dependency tree re-enables the GIL, you inherit the overhead without the parallelism. That is the worst of both worlds.
My decision rule: adopt free-threading when you have measured CPU-bound pure-Python parallelism as the bottleneck and your dependencies declare support. Not before, and not because the version number moved.
A Migration Checklist You Can Run
Turn the reasoning into evidence about your own codebase. Run these in order.
- Confirm the build. Check
sysconfig.get_config_var("Py_GIL_DISABLED")for build support andsys._is_gil_enabled()for the live process state. They answer different questions. - Expose latent races early. Run your existing test suite with a short thread switch interval on the standard build. Fix what breaks there first.
- Run the same suite on the free-threaded build with the GIL forced off. Fail the build on any GIL re-enable warning. The warning is import-time, so make sure your test runner is not swallowing it.
- Benchmark your workload, not a generic suite. Compare single-thread overhead and multi-core throughput on your own data. The overhead tax is workload- and platform-dependent, so someone else's numbers are a starting hypothesis, not an answer.
- Record which dependencies re-enable the GIL. That list is the real migration blocker. Everything else is a benchmark away from a decision.
The GIL was never your thread-safety guarantee, and free-threading does not hand you one. What it removes is an accidental serialization you may have been depending on without knowing it. Start with step one: print sys._is_gil_enabled() in your own process and find out which interpreter you are actually running. The answer is often not the one you installed.
Knowledge check
Final check
Finish the article by checking the ideas you just learned.
References
Research updated Sep 11, 2026


