The GIL
Why a Python process runs one bytecode instruction at a time, when that hurts you, and when it doesn't.
What you'll learn
- What the GIL actually is — and what it is not
- Why threads speed up I/O-bound code but not CPU-bound code
- How to tell which kind of work you have
- Where free-threaded Python (PEP 703) changes the story
Before you start
The Global Interpreter Lock (GIL) is a mutex — a mutual-exclusion lock — inside CPython that allows only one thread to execute Python bytecode at a time, per process. It is the single most misunderstood thing in Python performance, and the misunderstanding is expensive: it sends people reaching for threads where threads cannot help, and away from them where they would help enormously. Get the GIL right and your concurrency choices stop being guesses.
What the GIL actually does
Every CPython process holds one lock, and to run a single bytecode instruction a thread must hold it. The interpreter releases the lock periodically — roughly every few milliseconds, and whenever a thread waits on I/O — so other threads get a turn. The consequence is stark: you may have ten threads, but only one of them is executing Python code at any given instant.
That is the whole mechanism. The GIL is not a lock on your data, not a per-object thing, and not present in every Python implementation. It exists because CPython manages memory with reference counting on every object, and that counting is not thread-safe — without the GIL, two threads bumping the same refcount at once would corrupt memory.
The difference between work that the GIL throttles and work it does not is best seen as a timeline:
CPU-bound: threads do not help
Compute-heavy work that stays in Python bytecode gains nothing from threads — they simply take turns on the one GIL, so the total work is unchanged and you only add overhead:
import time
from concurrent.futures import ThreadPoolExecutor
def crunch(n):
# Pure-Python CPU work — holds the GIL the whole time.
total = 0
for i in range(n):
total += i * i
return total
N = 2_000_000
t0 = time.perf_counter()
[crunch(N) for _ in range(4)]
serial = time.perf_counter() - t0
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=4) as pool:
list(pool.map(crunch, [N] * 4))
threaded = time.perf_counter() - t0
print(f"serial: {serial:.2f}s")
print(f"threaded: {threaded:.2f}s")
print(f"speedup: {serial / threaded:.2f}x")
Run this and the result is the lesson: threaded comes out roughly equal to serial — often a hair slower, because of the context-switching overhead. The four threads executed exactly the same total bytecode; the GIL just made them take turns, so the speedup hovers around 1×, not 4×. For real CPU parallelism you need processes (the next lesson but one) or libraries like NumPy and Polars, which release the GIL inside their C code.
I/O-bound: threads help a lot
When a thread waits on the network, a disk read, or a database query, it releases the GIL — and while it waits, the other threads run. This is where threading pays off enormously:
import time
from concurrent.futures import ThreadPoolExecutor
def fake_http_call(url):
# Simulate a slow network call — time.sleep releases the GIL.
time.sleep(0.2)
return f"OK {url}"
urls = [f"https://api.example.com/item/{i}" for i in range(8)]
t0 = time.perf_counter()
[fake_http_call(u) for u in urls]
serial = time.perf_counter() - t0
t0 = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as pool:
list(pool.map(fake_http_call, urls))
threaded = time.perf_counter() - t0
print(f"serial: {serial:.2f}s")
print(f"threaded: {threaded:.2f}s")
print(f"speedup: {serial / threaded:.2f}x")
Here the outcome is predictable from arithmetic rather than luck. Eight calls that each sleep 0.2s take about 8 × 0.2 = 1.6s in series. But with eight threads the sleeps all overlap — each releases the GIL the moment it starts waiting — so the whole batch finishes in roughly 0.2s, an 8× speedup. The same holds for real socket reads, file reads, and most C-extension I/O: crypto, compression, image decoding, and nearly everything in NumPy, pandas, Polars, and PyArrow all release the GIL, because the work is happening in C, not in Python bytecode.
A useful mental classification
Before reaching for threads, processes, or async, classify the workload by where the time goes:
| Work | Where time is spent | What to use |
|---|---|---|
| HTTP fan-out, scraping, API calls | Network wait | Threads or asyncio |
| Read 1000 files, query a DB | Disk / DB wait | Threads or asyncio |
| Tokenise, hash, parse JSON in pure Python | Python bytecode | Processes |
| NumPy matrix multiply, Polars groupby | C code (GIL released) | Threads work fine |
| Pillow resize, gzip compress | C code | Threads work fine |
When in doubt, profile: time it serial, then with a thread pool. If it does not speed up, you have CPU-bound Python work and you need processes.
The free-threaded future (PEP 703)
Python 3.13 first shipped an experimental no-GIL build, and 3.14 made it an officially supported (still opt-in) option — you install a separate “free-threaded” interpreter such as python3.14t. It makes real multi-core threading possible, so pure-Python CPU code can finally scale across cores. The catch is twofold: C extensions must be updated to be safe without the GIL, and single-threaded code runs a touch slower (around 5–10%) due to the extra locking.
PEP 703 (Sam Gross’s “nogil” proposal, accepted in 2023) lays out a phased rollout: experimental in 3.13, supported-but-opt-in in 3.14, and expected to become the default only in a later release once C-extension support is broad. NumPy, PyTorch, and the major scientific stack already ship free-threaded wheels. For now, write code as if the GIL exists — because for any non-experimental deployment, it does.
In one breath
- The GIL lets only one thread run Python bytecode at a time per process.
- CPU-bound pure-Python work gets no speedup from threads — use processes.
- I/O-bound work does speed up, because waiting threads release the GIL.
- C-heavy libraries (NumPy, Polars, Pillow) release the GIL, so threads parallelise their work.
- Free-threaded Python (PEP 703) is arriving but opt-in — code as if the GIL is there.
Practice
Quick check
What’s next
The GIL is the why. The next two lessons are the how: threading with ThreadPoolExecutor for I/O fan-out, and multiprocessing for when you truly must bypass the GIL.
Practice this in an interview
All questionsCPython uses reference counting as its primary mechanism: every object carries a counter of how many names or containers point to it, and the object is freed immediately when the count hits zero. Because reference counting cannot detect cycles, Python also runs a cyclic garbage collector that periodically finds and collects groups of objects that refer only to each other.
Python variables are names — they store references (pointers) to objects, not the objects themselves. Assignment binds a name to an object; it never copies the object. Understanding this explains why mutating an object through one name is visible through all other names that reference the same object.
The Global Interpreter Lock is a mutex inside CPython that allows only one thread to execute Python bytecode at a time. It exists because CPython's memory management — reference counting — is not thread-safe, so the GIL prevents race conditions on object reference counts without per-object locking overhead.
Use threading for I/O-bound work — network calls, file reads, database queries — because threads release the GIL during blocking syscalls and share memory cheaply. Use multiprocessing for CPU-bound work — number crunching, image processing — because each process gets its own GIL and can run on a separate core.