GIL & CPython
The Global Interpreter Lock is a CPython implementation detail, not a Python language rule -- understanding it explains why CPU-bound threads don't speed up under traditional CPython builds.
The Global Interpreter Lock (GIL) is one of the most misunderstood aspects of Python. It is not a feature of the Python language itself -- it is an implementation detail of CPython, the reference implementation most people run. Other implementations (Jython, IronPython, and CPython's own free-threaded builds) do not have it, so it is never correct to say 'Python has a GIL' as a blanket language guarantee; the accurate statement is that traditional CPython builds have a GIL.
What the GIL Is in CPython
CPython implementation detail: the GIL is a mutex (lock) that ensures only one thread executes Python bytecode at a time within a single CPython process, even on a multi-core machine. Any thread that wants to run Python code must first acquire the GIL; only one thread holds it at any given moment.
The GIL exists primarily because CPython's memory management uses reference counting (see the memory-management topic) for most object lifetime tracking, and reference counts are updated extremely frequently -- on nearly every object access. Without a global lock, two threads incrementing/decrementing the same object's refcount simultaneously could corrupt it, causing crashes or memory leaks. The GIL was the simplest solution when CPython's threading model was designed, and it also simplifies the C API that many extension modules rely on.
Why It Matters
The practical consequence: under traditional CPython, creating multiple threads to run pure-Python, CPU-bound code does not make that code run faster on multiple cores, because only one thread can execute Python bytecode at a time. This surprises developers coming from languages without a GIL, where more threads on a multi-core machine straightforwardly means more parallel computation.
Under traditional CPython, the two-thread version is typically no faster than the sequential version (often slightly slower due to GIL hand-off overhead), because both threads compete for the same GIL when running pure-Python bytecode.
import threading
import time
def cpu_bound_count(n: int) -> None:
x = 0
for _ in range(n):
x += 1
start = time.perf_counter()
cpu_bound_count(50_000_000)
cpu_bound_count(50_000_000)
print(f"sequential: {time.perf_counter() - start:.2f}s")
start = time.perf_counter()
threads = [threading.Thread(target=cpu_bound_count, args=(50_000_000,)) for _ in range(2)]
for t in threads:
t.start()
for t in threads:
t.join()
print(f"two threads: {time.perf_counter() - start:.2f}s") # not meaningfully fasterI/O-Bound Versus CPU-Bound Workloads
The GIL's impact depends entirely on what kind of work threads are doing. For I/O-bound work (waiting on network responses, disk reads, database queries), CPython releases the GIL while a thread is blocked on I/O, so other threads can run Python code during that wait. This means threading is genuinely effective for I/O-bound concurrency in traditional CPython -- many threads can be usefully overlapped even though only one runs Python bytecode at any instant.
For CPU-bound work (tight numeric loops, heavy computation in pure Python), threads provide little to no benefit under traditional CPython, because there's no I/O wait during which the GIL gets released -- one thread just holds the GIL almost continuously while doing the math.
Many C-implemented libraries (NumPy for large array operations, zlib compression, hashlib, some I/O in the standard library) release the GIL internally while doing their heavy lifting in C, so threads calling into them CAN see real parallel speedup even for what looks like CPU-bound work -- the GIL constraint applies to Python bytecode execution, not to arbitrary C code running under the interpreter's roof.
Workload type | threading (CPython, traditional) | multiprocessing | asyncio
--------------------|-----------------------------------|------------------|------------------
I/O-bound | Effective (GIL released on I/O) | Works, heavier | Effective, lighter
CPU-bound (pure Py) | Ineffective (GIL serializes) | Effective | Ineffective
CPU-bound (C ext.) | Can be effective if C releases GIL| Effective | N/AMultiprocessing as the Escape Hatch
Because the GIL is per-interpreter-process, spawning multiple OS processes (see the Multiprocessing and Process Pools topics) sidesteps it entirely -- each process has its own interpreter and its own GIL, so processes can run Python bytecode fully in parallel across cores. This is why multiprocessing/ProcessPoolExecutor, not threading, is the standard answer for parallelizing CPU-bound pure-Python work under traditional CPython.
from concurrent.futures import ProcessPoolExecutor
import time
def cpu_bound_count(n: int) -> None:
x = 0
for _ in range(n):
x += 1
if __name__ == "__main__":
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=2) as ex:
list(ex.map(cpu_bound_count, [50_000_000, 50_000_000]))
print(f"two processes: {time.perf_counter() - start:.2f}s") # meaningfully faster on multi-coreImplementation-Specific Nature
It is critical to say precisely: the GIL is a property of CPython's traditional build, not a requirement of the Python language specification. Other implementations demonstrate this:
Jython (Python on the JVM) and IronPython (Python on .NET) have historically had no GIL, using their host runtime's native threading and garbage collection instead. PyPy (a JIT-compiled implementation) has traditionally also used a GIL in its default builds, for reasons similar to CPython's, though its internals differ. The existence of GIL-free implementations proves the GIL is an implementation choice, not a language-level guarantee -- code that depends on GIL behavior for correctness (e.g. assuming a single bytecode operation is atomic across threads) is relying on a CPython implementation detail, not portable Python semantics.
Modern CPython Evolution: Free-Threaded Builds
CPython implementation detail, evolving: PEP 703, accepted for phased rollout starting with Python 3.13, introduces an optional 'free-threaded' build of CPython (sometimes referred to informally as 'nogil') that removes the GIL, using more fine-grained locking and a different memory-management approach (including biased reference counting) to keep thread safety without one global lock. This build is distributed alongside the traditional GIL build, opted into explicitly (e.g. the python3.13t / python3.13-freethreaded interpreter build), and as of the mid-2020s is still stabilizing -- C-extension compatibility, single-threaded performance overhead, and ecosystem support are all actively evolving areas.
This is precisely why the accurate statement is 'CPython (traditional builds) has a GIL' rather than 'Python has a GIL' -- as free-threaded builds mature, an increasing share of real-world CPython deployments may run without one, while the standard/default build still has it. Always check sys._is_gil_enabled() (3.13+) if code needs to detect this at runtime rather than assuming either way.
Rules and Behavior
CPython implementation detail: the GIL is released periodically even during pure-Python CPU-bound loops (governed by the 'switch interval', configurable via sys.setswitchinterval()), so multiple threads still take turns running -- this provides fairness and responsiveness (e.g. one thread doesn't fully starve others) but does not provide parallel speedup, since only one thread runs Python bytecode during any given slice.
The GIL also does not make all Python code automatically thread-safe in the way people sometimes assume -- individual bytecode operations are atomic under the GIL, but a sequence of operations that together form a 'logical' operation (like x += 1, which is actually a read, an add, and a write) is not atomic as a whole, so race conditions on shared mutable state are still very possible with threads and still require locks (see the Locks and Thread Safety topics) even under a GIL build.
Practical Usage
The practical rule of thumb for choosing a concurrency tool under traditional CPython: use threading (or asyncio) for I/O-bound work; use multiprocessing/ProcessPoolExecutor for CPU-bound pure-Python work; for CPU-bound work that's mostly happening inside a well-optimized C extension (NumPy, pandas' vectorized operations), threads can sometimes still help because the extension releases the GIL during its C-level computation.
Mental Model
Picture traditional CPython as a kitchen with many chefs (threads) but only one knife (the GIL) for actual Python-bytecode chopping. Chefs can prep other things simultaneously (I/O waits, or work happening inside C extensions that don't need the shared knife), but whenever a chef needs to chop Python bytecode, they must have the knife, and only one chef holds it at a time -- more chefs doesn't speed up chopping-bound work, it just adds chefs waiting their turn for the knife.
Quick Reference
GIL = mutex serializing Python bytecode execution, in TRADITIONAL CPython only
Released during: blocking I/O, some C extension calls (NumPy, zlib, ...)
NOT released during: tight pure-Python CPU loops
I/O-bound -> threading / asyncio work well even with the GIL
CPU-bound (pure Python) -> use multiprocessing, not threading
Free-threaded CPython (PEP 703, 3.13+, opt-in) -> no GIL, still stabilizing
sys._is_gil_enabled() -> check at runtime (3.13+)Key Takeaways
The GIL is a CPython implementation detail -- correctly stated as 'CPython (traditional builds) has a GIL', never 'Python has a GIL' as a language guarantee.
The GIL exists mainly to keep CPython's reference-counting memory management thread-safe without fine-grained locking everywhere.
It makes threading effective for I/O-bound work (the GIL is released during I/O waits) but ineffective for pure-Python CPU-bound work.
C extensions that release the GIL during heavy computation (NumPy, etc.) can still benefit from threads even for CPU-heavy tasks.
Multiprocessing sidesteps the GIL entirely by using separate interpreter processes, each with its own GIL.
PEP 703 free-threaded CPython builds (3.13+, opt-in) remove the GIL and are an evolving alternative, not yet the default.
Blanket claims like 'Python has a GIL' conflate the language with one implementation's traditional build, and overstate threading's uselessness -- it's ineffective specifically for CPU-bound pure-Python code, not for concurrency generally.
import threading counter = 0 def increment(): global counter for _ in range(1_000_000): counter += 1 # assumed "safe" because "the GIL protects everything" threads = [threading.Thread(target=increment) for _ in range(4)] [t.start() for t in threads] [t.join() for t in threads] print(counter) # often NOT 4_000_000 -- a race condition import threading counter = 0 lock = threading.Lock() def increment(): global counter for _ in range(1_000_000): with lock: counter += 1 threads = [threading.Thread(target=increment) for _ in range(4)] [t.start() for t in threads] [t.join() for t in threads] print(counter) # 4_000_000
The GIL guarantees individual bytecode operations are atomic, but counter += 1 is multiple bytecode operations (read, add, store); the GIL can switch threads between them, so an explicit lock is still needed for correctness.
Threads competing for the same GIL provide no parallel speedup for pure-Python CPU-bound work under traditional CPython; separate processes, each with their own GIL, do.
Why is it inaccurate to say 'Python has a GIL'?
The GIL is a CPython implementation detail, not a Python language requirement; other implementations (historically Jython, IronPython) have no GIL, and even CPython now offers optional free-threaded builds (PEP 703, 3.13+) without one, so only traditional CPython builds can accurately be said to have a GIL.
Why does the GIL primarily exist in CPython?
To keep CPython's reference-counting-based memory management thread-safe without needing fine-grained locks around every object's refcount updates, which happen extremely frequently.
Why does threading remain effective for I/O-bound work under traditional CPython despite the GIL?
CPython releases the GIL while a thread is blocked on I/O (network, disk, etc.), allowing other threads to run Python bytecode during that wait, so multiple I/O-bound threads can usefully overlap.
Why doesn't threading speed up pure-Python CPU-bound loops under traditional CPython?
Because there's no I/O wait during which the GIL gets released; one thread holds the GIL almost continuously while computing, so only one thread's bytecode executes at a time regardless of core count.
How does multiprocessing avoid the GIL's limitation on CPU-bound parallelism?
Each process runs its own separate Python interpreter with its own GIL, so multiple processes can execute Python bytecode fully in parallel across CPU cores.
Does the GIL make all Python code automatically thread-safe? Why or why not?
No -- the GIL only guarantees individual bytecode operations are atomic; a compound operation like x += 1 involves multiple bytecode instructions, and the GIL can switch threads between them, so race conditions on shared mutable state are still possible and require explicit locks.
What is PEP 703 and how does it change the GIL picture?
PEP 703 introduces optional free-threaded CPython builds (from 3.13, opt-in) that remove the GIL using finer-grained thread safety mechanisms; it's an evolving alternative build, not yet the CPython default, so GIL presence now depends on which build is running.
Write a short comment-annotated code snippet correctly describing when threading helps versus multiprocessing, under traditional CPython.
Threading suits I/O-bound work because CPython releases the GIL during blocking I/O; multiprocessing suits CPU-bound pure-Python work because separate processes each get their own GIL and can run bytecode truly in parallel.
Under traditional CPython, does this reliably print True? Why or why not?
Not reliably -- it may print False. Even though a GIL exists, shared += 1 is not a single atomic bytecode operation (it's a load, add, and store), so the GIL can switch between the two threads mid-sequence, causing lost updates. A Lock around the increment would make it reliable.
Write code that checks at runtime whether the current CPython interpreter is running with the GIL enabled (3.13+), falling back gracefully on older versions.
import sys def gil_status() -> str: check = getattr(sys, "_is_gil_enabled", None) if check is None: return "unknown (pre-3.13 build; GIL presumed enabled on traditional CPython)" return "GIL enabled" if check() else "GIL disabled (free-threaded build)" print(gil_status()) sys._is_gil_enabled() (added in 3.13 for free-threaded build support) reports whether the GIL is active in the current process; on interpreters without this attribute, we fall back to noting the traditional assumption rather than asserting it unconditionally.
import threading
shared = 0
def worker():
global shared
for _ in range(500_000):
shared += 1
threads = [threading.Thread(target=worker) for _ in range(2)]
for t in threads: t.start()
for t in threads: t.join()
print(shared == 1_000_000)