The GIL (Global Interpreter Lock) is a mutex inside CPython, the standard Python interpreter, that lets only one thread execute Python bytecode at a time, even on a multi-core machine. It is why Python threads speed up I/O-bound code but not CPU-bound code, and why Python developers reach for multiprocessing for parallel computation.
What it does#
Compare four CPU-bound threads on a 4-core machine in a language without a GIL (such as C++, Go or Rust) and in CPython:
| Core 1 | Core 2 | Core 3 | Core 4 | |
|---|---|---|---|---|
| No GIL (true parallelism) | T1 runs | T2 runs | T3 runs | T4 runs |
| CPython (one thread runs Python at a time) | T1 runs | idle | idle | idle |
For CPU-bound work, 4 threads in a no-GIL language can give close to a 4× speedup. 4 CPython threads run about as fast as 1.
Why it exists#
CPython manages memory with reference counting. Each object counts how many references point to it and is freed when the count reaches zero.
a = [1, 2, 3] # list refcount = 1
b = a # refcount = 2
del a # refcount = 1
del b # refcount = 0 → freed
If two threads updated a refcount at the same time, that would be a race condition: counts could drift, causing leaks (never reaching 0) or crashes (freeing an object still in use).
CPython's designers had two options:
- Option A: fine-grained locks on every object's refcount. Every assignment would take and release a lock, slowing down single-threaded programs, which is most Python code.
- Option B: one big lock on the whole interpreter. Simple, and single-threaded code pays nothing extra. Multi-threaded CPU-bound code suffers.
They chose B. It optimized for the common case at the cost of the rarer one.
When the GIL doesn't matter: I/O#
A thread releases the GIL while it waits on blocking I/O (socket reads, requests.get(), file reads, time.sleep()), so other threads run in the meantime.
import requests
from concurrent.futures import ThreadPoolExecutor
def fetch(url):
# GIL is released while waiting on the network
return requests.get(url).status_code
urls = ["https://example.com"] * 10
with ThreadPoolExecutor(max_workers=10) as pool:
# Roughly 10x faster than fetching one by one: all ten wait in parallel
print(list(pool.map(fetch, urls)))
def compute(n):
total = 0
for i in range(n):
total += i * i # pure Python: holds the GIL the whole time
return total
# Running this in 10 threads takes about as long as running it 10 times in a row
| Thread is doing... | GIL held? | Runs in parallel? |
|---|---|---|
| CPU work in pure Python (own data or shared) | Yes | No |
| Waiting on I/O (DB, network, disk) | Released | Yes |
| Heavy work inside a C extension that releases the GIL (many NumPy operations, for example) | Released during that work | Yes |
Getting real parallelism: multiprocessing#
Each process has its own interpreter and its own GIL.
from multiprocessing import Pool
def compute(n):
total = 0
for i in range(n):
total += i * i
return total
if __name__ == "__main__": # required on platforms that spawn new processes
with Pool(processes=4) as pool:
# Four interpreters, four GILs → uses four cores
print(pool.map(compute, [10_000_000] * 4))
concurrent.futures.ProcessPoolExecutor offers the same thing with the futures API.
The cost: processes don't share memory. Arguments and results are pickled and copied between processes, which is slow for big data. Passing a 2 GB DataFrame to 8 workers can mean ~16 GB of RAM.
Why the GIL gets criticized#
You still need locks in threaded Python. The GIL protects the interpreter's internals, not your data. counter += 1 is several bytecodes, and a thread switch can happen between them.
| No-GIL language (C++, Go, Rust) | CPython (with GIL) | |
|---|---|---|
| CPU-bound parallelism with threads | Yes | No |
| Need locks for shared data | Yes | Still yes |
| Threading overhead | Pays off with real speedup | Same complexity, no speedup for CPU work |
An illustrative (not measured) example of the effect on an 8-core machine processing images with CPU-heavy transforms:
| Setup | Speed vs 1 thread |
|---|---|
| No-GIL language, 8 threads | ~8× faster |
| CPython, 8 threads | About the same |
| CPython, 8 processes | Close to 8×, but with extra memory and copy costs |
The complaints boil down to three things:
- Python is popular for data and ML work, which is often CPU-heavy (much of it is rescued by C extensions like NumPy that release the GIL).
- The workaround is clunky: multiprocessing means serialization and duplicated memory.
- Other languages (C++, Go, Rust) have had true multi-threaded parallelism for a long time.
Why not just remove it? Thousands of C extensions were written assuming the GIL protects them, so removing it breaks compatibility and needs careful, gradual migration.
The free-threaded future#
PEP 703 makes the GIL optional. Python 3.13 shipped an experimental free-threaded build (often installed as python3.13t) that uses finer-grained, per-object locking instead. Single-threaded code runs somewhat slower in that build, and C extensions must be updated to support it. Python 3.14 moved the free-threaded build from experimental to officially supported, though the default build still has a GIL.
Other Python implementations differ too: Jython and IronPython never had a GIL; PyPy has one.
Comparison#
Here is how CPython stacks up against languages without a GIL.
| CPython | C++ / Go | |
|---|---|---|
| GIL | Yes (optional in free-threaded builds) | No |
| CPU-bound threading | No speedup | True parallelism |
| I/O-bound threading | Works well (GIL released while waiting) | Works well |
| CPU parallelism fix | multiprocessing, C extensions, free-threaded build |
Just use threads |
| Root cause | Reference counting isn't thread-safe without it | No interpreter-wide refcounting to protect |
Key takeaways#
- The GIL lets one thread at a time run Python bytecode in CPython.
- It exists to keep reference counting safe without per-object locks.
- Threads help I/O-bound Python (the GIL is released while waiting) but not CPU-bound pure Python.
- Use
multiprocessing/ProcessPoolExecutorfor CPU parallelism, at the cost of copying data between processes. - You still need locks for shared data; the GIL protects the interpreter, not your invariants.
- PEP 703's free-threaded build makes the GIL optional, starting in Python 3.13.