datarekha

The GIL

Why a Python process runs one bytecode instruction at a time, when that hurts you, and when it doesn't.

8 min read Advanced Python Lesson 30 of 41

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 — serialized by the GILworkthread 1thread 2thread 3total = t1 + t2 + t3 → no speedup (~1×)I/O-bound + threads — waits overlap (GIL released)t1t2t3all waits overlap → finish in ~one wait (big speedup)

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:

WorkWhere time is spentWhat to use
HTTP fan-out, scraping, API callsNetwork waitThreads or asyncio
Read 1000 files, query a DBDisk / DB waitThreads or asyncio
Tokenise, hash, parse JSON in pure PythonPython bytecodeProcesses
NumPy matrix multiply, Polars groupbyC code (GIL released)Threads work fine
Pillow resize, gzip compressC codeThreads 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

0/4
Q1You have a function that downloads 50 URLs and parses the JSON. Where does it spend most of its time?
Q2Why doesn't a `ThreadPoolExecutor` speed up a pure-Python CPU loop?
Q3Which of these scale across cores in threaded code today?select all that apply
Q4A colleague rewrites a slow pure-Python CSV parser in NumPy, runs it with `ThreadPoolExecutor(max_workers=4)`, and gets nearly 4× speedup. Expected?

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.

Sign in to track your progress

Completed lessons, your XP, level, and streak save to your account — it's free and takes a few seconds.

Practice this in an interview

All questions
How does Python manage memory — reference counting and the garbage collector?

CPython 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.

In Python, do variables store values or references to objects?

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.

What is Python's GIL and why does it exist?

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.

When should you use threading versus multiprocessing in Python?

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.

Related lessons

Explore further

Skip to content