Home Tutorials Concurrency

Concurrency

The Global Interpreter Lock: Why Python Threads Don't Run in Parallel

Pyford Notes July 6, 2026 8 min read
Key points
  • Only one thread runs Python bytecode at a time in a standard CPython process, no matter how many CPU cores are available.
  • The GIL protects CPython's reference-counting memory management from concurrent corruption, not your application logic.
  • Threads still help when they spend most of their time waiting on I/O, because the GIL is released during blocking system calls.
  • CPU-bound work needs multiprocessing, a C extension that releases the GIL, or a free-threaded build to actually run in parallel.

What the GIL actually locks

The Global Interpreter Lock is a single mutex inside the CPython interpreter. Before any thread can execute Python bytecode, it has to hold that lock. Only one thread holds it at any instant, so on a normal CPython build, two threads never execute Python instructions at the same moment, even on an eight-core machine. This surprises people coming from languages where threads map directly onto hardware parallelism — in CPython, threads give you concurrency (interleaved progress) but not parallelism (simultaneous execution) for pure Python code.

The interpreter switches which thread holds the lock periodically, roughly every few milliseconds by default (controlled by sys.setswitchinterval()), or whenever the running thread blocks on I/O. So a multi-threaded Python program does make progress on several threads over time, it just never does two things at literally the same instant inside the interpreter loop.

Why CPython added it in the first place

CPython manages memory with reference counting: every object carries a count of how many references point to it, and the count changes on nearly every assignment, function call, and scope exit. If two threads could increment and decrement that count at the same time without coordination, the count could end up wrong — an object could be freed while something still points at it, or never freed at all. Fine-grained locking around every object's reference count would work, but it's expensive and easy to get wrong, and early attempts at it (and later ones, in the 1999 "free threading" patch) made single-threaded code noticeably slower, which was the workload CPython was optimized for at the time.

The GIL is the blunt-instrument alternative: one lock around the whole interpreter instead of thousands of small locks around individual objects. It keeps single-threaded performance fast and makes the C implementation of built-in types simpler, at the cost of preventing true parallel execution of Python bytecode.

# Two threads doing pure CPU work: total time is roughly the SAME
# as running them one after another, not half, because of the GIL
import threading, time

def spin(n):
    x = 0
    for _ in range(n):
        x += 1

start = time.perf_counter()
t1 = threading.Thread(target=spin, args=(20_000_000,))
t2 = threading.Thread(target=spin, args=(20_000_000,))
t1.start(); t2.start()
t1.join(); t2.join()
print(time.perf_counter() - start)   # close to running spin() twice serially

I/O-bound threads still win, CPU-bound ones don't

The GIL is released automatically whenever a thread calls into a blocking operation implemented in C — reading from a socket, waiting on a file descriptor, sleeping, waiting on a database driver's network round trip. While one thread is blocked waiting for the operating system, the GIL is free for another thread to pick up and run Python code. This is exactly why threading works well for network servers, web scrapers, and anything dominated by waiting: the threads spend most of their lives outside the lock.

Pure computation is the opposite case. A thread doing arithmetic or list processing never calls a blocking system call, so it holds the GIL for its entire time slice, forcing every other thread — including ones that would otherwise be free to run — to wait. Adding threads to a CPU-bound Python program typically does not speed it up, and can slow it down slightly due to the overhead of switching the lock back and forth.

How C extensions release the GIL

Extension modules written in C are not bound by the same restriction inside their own code. NumPy, for example, releases the GIL before running a long numerical operation implemented in C, and reacquires it only when control needs to come back into the Python interpreter. That's why numpy.dot() on large arrays can use multiple cores even in a GIL-bound Python process: the actual number crunching happens outside the lock's reach entirely.

// Simplified shape of what a C extension does around a long computation
Py_BEGIN_ALLOW_THREADS
    result = expensive_c_function(data, length);  // GIL released here
Py_END_ALLOW_THREADS
// GIL reacquired before touching Python objects again

This is also why compression libraries, cryptographic hashing, and compiled regex matching in the standard library can overlap with other threads doing Python work: the C implementations release the lock during the heavy part.

Working around it: processes, subinterpreters, free-threading

multiprocessing sidesteps the GIL entirely by running each worker in its own operating system process, each with its own interpreter and its own GIL. That gives real parallelism for CPU-bound work, at the cost of needing to serialize data between processes (usually with pickling) instead of sharing memory directly.

PEP 554 introduced subinterpreters with separate GILs within a single process, and PEP 703 (accepted for CPython 3.13 as an experimental build option) removes the GIL entirely from a special "free-threaded" build. Both are worth watching, but as of the versions most people run in production, the practical rule of thumb hasn't changed: reach for threads when the bottleneck is waiting, reach for processes when the bottleneck is computation, and check whether the library doing your heavy lifting already releases the GIL before assuming you need either.