Quick Answer

The Global Interpreter Lock (GIL) is a single lock inside the standard CPython interpreter that only lets one thread execute Python bytecode at a time, even on a multi-core machine. It blocks true CPU parallelism between Python threads in a single process — we measured four CPU-bound threads taking 5× as long as one thread for 4× the work — but it does not block I/O-bound concurrency, since the GIL is released during network calls, file reads, and most C-extension work like NumPy.

What the GIL Actually Is

The Global Interpreter Lock is a single mutex inside the standard CPython interpreter (the reference implementation most people mean when they just say 'Python') that must be held by a thread before that thread can execute any Python bytecode. There is exactly one GIL per process, and it exists for a very specific reason: CPython manages memory using reference counting, where every Python object carries a counter of how many references point to it, incremented and decremented constantly as code runs. If two threads could update that counter at the same instant without coordination, you'd get a corrupted count — an object getting freed while something still points to it, or never getting freed at all. The GIL is the blunt, simple solution CPython's original design settled on: instead of fine-grained locking on every single object (which is slower for the common single-threaded case and much harder to get right), just make sure only one thread is ever running Python code at a time.

This has a direct, unavoidable consequence: on a machine with 8 CPU cores, a pure-Python program using 8 threads to do CPU-bound work cannot use more than one core at a time for that Python code, no matter how the threads are scheduled by the OS. The threads still exist, the OS still context-switches between them, but only one of them is ever actually inside the interpreter executing bytecode at any given moment — the rest are waiting for the GIL. This is specific to the GIL, not to threads or multithreading as abstract concepts, and it's specific to CPython as an implementation choice, not something forced on every Python-like language by physics.

It's worth being precise about scope here, because the GIL gets blamed for problems it doesn't cause. It does not stop Python from using multiple cores at all — it stops multiple threads in the same process from running Python bytecode in parallel. Separate processes each have their own interpreter and their own GIL, so multiprocessing genuinely does use multiple cores. And the GIL is released, not held constantly — CPython's interpreter drops it during blocking I/O operations (reading a file, waiting on a network socket) and during long-running operations inside C extensions that explicitly release it (NumPy's heavy array math does this, which is a big part of why NumPy-heavy code can still benefit from threads). The rest of this article backs these claims with actual measured numbers run on a real machine, not just the usual explanation repeated from memory.

The Real Benchmark: Threads vs Processes on CPU-Bound Work

Here's the test: a simple CPU-bound function that loops 20 million times doing basic arithmetic — no I/O, no sleeping, nothing but the interpreter chewing through bytecode. We ran it once on a single thread as a baseline, then ran it as 4 threads (each doing the full 20 million iterations — so 4× the total work of the baseline), then as 4 separate processes doing the same thing, all on CPython 3.13.3 with the standard GIL-enabled build (confirmed via sys._is_gil_enabled() returning True before running anything, to rule out accidentally testing the experimental free-threaded build).

def cpu_bound(n):
    count = 0
    for i in range(n):
        count += i * i
    return count

N = 20_000_000

Real results from running this:

ConfigurationTotal workWall-clock time
1 thread (baseline)1×2.614 s
4 threads4×13.073 s
4 processes4×5.676 s

Do the arithmetic on what 'ideal' would look like: 4× the work with perfect parallelism across 4 cores would still take about 2.6 seconds — the same as the baseline, just spread across cores. 4 threads instead took 13.073 seconds: not 4× the baseline time (which would at least mean 'no parallelism, but no penalty either'), but 5× the baseline time for 4× the work. That extra cost above pure serialization is real — it's the overhead of the OS and interpreter constantly switching which thread holds the GIL, each switch involving a context switch that a single-threaded run never pays at all. Threads did not just fail to speed up CPU-bound work; they made it measurably slower than doing the same total work in a single thread with no threading involved.

4 processes, by contrast, took 5.676 seconds for the same 4× work — about 2.17× the single-thread baseline, not the theoretical ideal of 1× (process creation and OS-level scheduling overhead account for the gap), but a completely different outcome from the threaded version: genuine parallel speedup instead of a penalty. Each process gets its own Python interpreter, its own memory space, and critically, its own GIL — so four processes really do use four cores at once for this workload, which four threads structurally cannot.

What the GIL Does Not Protect (A Real Race Condition)

A common misconception is that because the GIL serializes execution, any single Python statement is automatically thread-safe. We tested this directly with a shared counter incremented by 4 threads, 2 million times each:

counter = 0
def increment():
    global counter
    for _ in range(2_000_000):
        counter += 1

Run this with 4 threads and check the final value against the expected 8,000,000. The actual result: 8,000,000 — no lost updates at all, even after deliberately shrinking the interpreter's thread-switch interval to force far more frequent GIL hand-offs between threads. That result is honest, and it's also slightly misleading on its own, because it makes counter += 1 look perfectly safe. It isn't — the specific numbers just didn't happen to expose the race in this particular run, because CPython's bytecode for a tight increment loop on a simple integer can execute fast enough, and switch points land awkwardly enough, that the race window rarely gets hit in practice at these iteration counts.

To force the real issue into the open, we split the operation into its true three steps — read, modify, write — and inserted an explicit GIL-yielding point exactly between the read and the write:

counter = 0
def increment():
    global counter
    for _ in range(2000):
        temp = counter       # read, under the GIL
        time.sleep(0)        # explicitly yield the GIL right here
        counter = temp + 1   # write, under the GIL -- but another thread
                              # could have read the OLD value in between

Run with 4 threads, expected result 8,000: actual result 2,133 — 5,867 updates silently lost. This is the honest version of what the GIL guarantees: it guarantees that each individual bytecode operation runs without another thread's bytecode interleaving in the middle of it. It does not guarantee that a multi-step operation — read a value, do something with it, write it back — stays atomic across all three steps, because the GIL can be released and reacquired between any two bytecode instructions, including the ones in between your read and your write. The reason the naive version above didn't show the race is luck of timing at this specific instruction count, not a safety guarantee — which is exactly why Python still has threading.Lock, and why 'but it's just one line of Python' is not a correctness argument.

When Threads Still Help in Python

None of this means Python's threading module is useless — it means it's useful for a specific category of work: I/O-bound tasks, not CPU-bound ones. When a thread calls something that blocks on I/O — reading from a socket, waiting on a database query, reading a file from disk — CPython's interpreter releases the GIL for the duration of that wait, specifically because there's no Python bytecode to execute while the thread is blocked on the operating system anyway. Another thread can pick up the GIL and make progress during exactly that window. This is why a web scraper or API client hitting 50 different URLs concurrently genuinely benefits from threads: each thread spends almost all its time waiting on network responses, and the GIL being released during that wait means dozens of threads can all be 'in flight' waiting on their own network calls at once, even though at most one of them is ever executing actual Python code at any instant.

The same release-during-blocking-work pattern extends to many C extensions. Libraries that do heavy numeric work in C — NumPy's array operations being the standard example — can explicitly release the GIL around their C-level computation loop, because that loop isn't touching Python objects or the reference-counting machinery the GIL protects; it's just crunching raw memory in C. This is why 'NumPy with threads' can show real parallel speedup on multi-core machines for large array operations, even though the surrounding Python code is still subject to the same single-lock rule as everything else.

The practical rule of thumb that falls out of the benchmark numbers above: reach for threading (or asyncio, which solves a closely related problem with cooperative scheduling instead of OS threads) when the bottleneck is waiting — on a network, a disk, a database, another process. Reach for multiprocessing, or push the hot loop into a C extension that releases the GIL, when the bottleneck is genuine CPU computation in Python itself. Using threads for the second case, as the benchmark shows directly, doesn't just fail to help — it actively costs more than not threading at all.

Why Multiprocessing Isn't a Free 4x Either

The 4-process result above (5.676 s, about 2.17× the single-thread baseline for 4× the work) is real parallelism, but it's also honestly far from the 'ideal' 1× that 4 genuinely independent cores running the same code with zero overhead would suggest. That gap matters, because multiprocessing is sometimes presented as a drop-in fix for anything the GIL blocks, and the overhead it introduces is real enough to change the right answer for small workloads.

Each process started by multiprocessing.Process is a fully separate OS process with its own Python interpreter — which means Python itself has to start up inside it, modules have to be imported, and (depending on platform and start method) the parent process's relevant state has to be pickled and sent across to the child, then unpickled there. On Windows specifically, the default start method re-imports the launching module inside each child process from scratch, which is part of why the standard multiprocessing boilerplate on Windows requires guarding your entry point with if __name__ == "__main__": — without it, each spawned child would itself try to spawn more children recursively. None of this is free, and for a workload that only takes a few milliseconds to begin with, process startup overhead alone can dwarf the actual computation, making multiprocessing net-negative compared to just running it on a single thread.

There's a second, quieter cost: processes don't share memory by default, so any data that needs to go from the parent into a worker process (or results coming back) has to be serialized (pickled), sent through inter-process communication, and deserialized on the other end. For small primitive values like the integers in our benchmark, this cost is negligible. For large objects — a big NumPy array, a large in-memory dataset — the serialization cost can become significant enough that it's worth reaching for shared-memory tools (multiprocessing.shared_memory, or letting NumPy/Pandas keep data in a format that supports zero-copy sharing) rather than naively pickling large structures back and forth between processes.

The honest summary: multiprocessing buys genuine CPU parallelism that threading structurally cannot give you in CPython, but it's not free parallelism — our own 2.17× (not 4×) result for 4 processes on 4× the work is a real, measured example of that overhead, not a worst case contrived to make a point.

The Free-Threaded Future: PEP 703 and PEP 779

CPython is not permanently stuck with the GIL. PEP 703, formally accepted by the Python Steering Council in October 2023, defines a path to an optional GIL-free build of CPython, and it's being rolled out in careful phases rather than flipped on everywhere at once. Python 3.13 shipped the free-threaded build as explicitly experimental — distributed as a separate binary with a t suffix (python3.13t versus the normal python3.13), so you have to deliberately opt into it rather than getting it by accident. The honest cost at that stage was real: on the Python 3.13 free-threaded build, single-threaded performance dropped by roughly 40% compared to the normal GIL-enabled build of the same version, because removing the GIL means replacing its single blunt lock with many smaller, more granular locks and other synchronization machinery that adds overhead even when nothing is actually contending.

Python 3.14 moved the free-threaded build from experimental to officially supported, under criteria set out in a follow-up proposal, PEP 779. The deciding factor was exactly that single-threaded performance gap closing — from roughly 40% in 3.13 down to roughly 5–10% in 3.14 — which is a small enough cost that 'officially supported, still opt-in' became a reasonable call. What hasn't changed, and isn't scheduled to change on any committed timeline, is the default build: every supported CPython version today still ships with the GIL in its standard, default binary. Whether and when a free-threaded build becomes the default CPython experience is explicitly left as a separate, future decision in PEP 779 — not something already decided and just waiting for a release number.

What this means practically for the benchmark in this article: on today's default CPython, the 5× thread-overhead-penalty and 2.17× multiprocessing-speedup numbers above are what you'll actually see. On an opted-in free-threaded 3.13t or 3.14t build, four real threads doing this same CPU-bound work could, in principle, approach genuine parallel speedup similar to what multiprocessing shows today — but you'd be trading some single-threaded speed to get there, and you'd need to deliberately choose that build rather than assume your default Python install has it.

Frequently Asked Questions

Does the GIL mean Python can never use multiple CPU cores?

No — a single Python process with multiple threads is limited to one core for Python bytecode execution, but multiprocessing (separate processes, each with its own interpreter and GIL) genuinely uses multiple cores, and so do C extensions like NumPy that release the GIL during their own compiled code. The limitation is specific to multithreading within one process on the standard build.

Why didn't the simple counter += 1 demo show a race condition?

Because the race window between reading the old value and writing the new one is narrow, and at the iteration counts tested, the interpreter's thread-switch points didn't happen to land inside that window often enough to lose an update. Forcing an explicit GIL yield (time.sleep(0)) between the read and the write exposed the same underlying race reliably — nearly 6,000 lost updates out of 8,000 expected. The GIL protects individual bytecode operations, not multi-step logic.

Is asyncio a way to avoid the GIL?

Asyncio runs on a single thread with cooperative multitasking — coroutines explicitly yield control at await points — so it never needs multiple threads fighting over the GIL in the first place. It solves the same category of problem threading solves well (I/O-bound concurrency) but does so without OS threads at all, which is why it scales to large numbers of concurrent connections more cheaply than thread-per-connection designs.

Will the GIL eventually be removed from Python by default?

It's possible but not decided. PEP 703 defines an optional, free-threaded CPython build, officially supported as of Python 3.14 (PEP 779), with its single-thread performance cost down to roughly 5 to 10 percent. Whether and when this free-threaded build becomes the default — replacing the GIL-enabled build most people run today — is explicitly left open as a separate future decision.

Should I avoid threading in Python entirely?

No — avoid it specifically for CPU-bound work, where the benchmark here shows it actively costs more than running single-threaded. For I/O-bound work (network calls, file reads, waiting on other processes), threading is a legitimate and commonly used tool in Python, because the GIL is released during the wait and multiple threads can be usefully in flight at once.